diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cf0182 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +/vendor/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ffac8b7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: php + +php: + - 5.3 + - 5.4 + - 5.5 + - hhvm + +before_script: + - composer install -n + +script: + - vendor/bin/phpunit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b02e078 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2009 James Golick + +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..fec2463 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +rollout (for php) +================= + +Feature flippers for PHP. A port of ruby's [rollout](https://github.com/FetLife/rollout). + +Install It +---------- + + composer require opensoft/rollout + +How it works +------------ + +Initialize a rollout object: + +```php +use Opensoft\Rollout\Rollout; +use Opensoft\Rollout\Storage\ArrayStorage; + +$rollout = new Rollout(new ArrayStorage()); +``` + +Check if a feature is active for a particular user: + +```php +$rollout->isActive('chat', $user); // returns true/false +``` + +Check if a feature is activated globally: + +```php +$rollout->isActive('chat'); // returns true/false +``` + +Storage +------- + +There are a number of different storage implementations for where the configuration for the rollout is stored. + +* ArrayStorage - default storage, not persistent +* DoctrineCacheStorageAdapter - requires `doctrine/cache` + +The storage implementation must implement the `Storage\StorageInterface`'s methods. + +Groups +------ + +Rollout ships with one group by default: `all`, which does exactly what it sounds like. + +You can activate the `all` group for chat features like this: + +```php +$rollout->activateGroup('chat', 'all'); +``` + +You may also want to define your own groups. We have one for caretakers: + +```php +$rollout->defineGroup('caretakers', function(RolloutUserInterface $user) { + return $user->isCaretaker(); // boolean +}); +``` + +You can activate multiple groups per feature. + +Deactivate groups like this: + +```php +$rollout->deactivateGroup('chat'); +``` + +Specific Users +-------------- + +You may want to let a specific user into a beta test or something. If that user isn't part of an existing group, you can let them in specifically: + +```php +$rollout->activateUser('chat', $user); +``` + +Deactivate them like this: + +```php +$rollout->deactivateUser('chat', $user); +``` + +Rollout users must implement the `RolloutUserInterface`. + +User Percentages +---------------- + +If you're rolling out a new feature, you may want to test the waters by slowly enabling it for a percentage of your users. + +```php +$rollout->activatePercentage('chat', 20); +``` + +The algorithm for determining which users get let in is this: + +```php +crc32($user->getRolloutIdentifier()) % 100 < $percentage +``` + +So, for 20%, users 0, 1, 10, 11, 20, 21, etc would be allowed in. Those users would remain in as the percentage increases. + +Deactivate all percentages like this: + +```php +$rollout->deactivatePercentage('chat'); +``` + +**Note:** Activating a feature for 100% of users will also make it activate `globally`. This is like calling `$rollout->isActive()` without a user object. + +Feature is Broken +----------------- + +Deactivate everybody at once: + +```php +$rollout->deactivate('chat'); +``` + +You may wish to disable features programmatically if monitoring tools detect unusually high error rates for example. + +Symfony2 Bundle +--------------- + +A Symfony2 bundle is available to integrate rollout into Symfony2 projects. It can be found at http://github.com/opensoft/OpensoftRolloutBundle. + +Implementations in other languages +---------------------------------- + +* Ruby: http://github.com/FetLife/rollout +* Python: http://github.com/asenchi/proclaim + +Copyright +--------- + +Copyright © 2010 James Golick, BitLove, Inc. See LICENSE for details. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6470078 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "opensoft/rollout", + "description": "Feature flippers for PHP", + "keywords": ["feature", "flag", "toggle", "rollout", "flipper"], + "license": "MIT", + "authors": [ + { + "name": "Richard Fullmer", + "email": "richard.fullmer@opensoftdev.com" + } + ], + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "doctrine/cache": "For use with the DoctrineCacheStorageAdapter" + }, + "require-dev": { + "phpunit/phpunit": "*", + "doctrine/cache": "*" + }, + "autoload": { + "psr-4": { + "Opensoft\\Rollout\\": "src/" + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..cf92f0f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + tests + + + + + + src/ + + + + diff --git a/src/Feature.php b/src/Feature.php new file mode 100644 index 0000000..f06b917 --- /dev/null +++ b/src/Feature.php @@ -0,0 +1,213 @@ + + */ +class Feature +{ + /** + * @var array + */ + private $name; + + /** + * @var array + */ + private $groups = array(); + + /** + * @var array + */ + private $users = array(); + + /** + * @var integer + */ + private $percentage = 0; + + /** + * @param string $name + * @param string|null $settings + */ + public function __construct($name, $settings = null) + { + $this->name = $name; + if ($settings) { + list($rawPercentage, $rawUsers, $rawGroups) = explode('|', $settings); + $this->percentage = (int) $rawPercentage; + $this->users = !empty($rawUsers) ? explode(',', $rawUsers) : array(); + $this->groups = !empty($rawGroups) ? explode(',', $rawGroups) : array(); + } else { + $this->clear(); + } + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param integer $percentage + */ + public function setPercentage($percentage) + { + $this->percentage = $percentage; + } + + /** + * @return integer + */ + public function getPercentage() + { + return $this->percentage; + } + + /** + * @return string + */ + public function serialize() + { + return implode('|', array( + $this->percentage, + implode(',', $this->users), + implode(',', $this->groups) + )); + } + + /** + * @param RolloutUserInterface $user + */ + public function addUser(RolloutUserInterface $user) + { + if (!in_array($user, $this->users)) { + $this->users[] = $user->getRolloutIdentifier(); + } + } + + /** + * @param RolloutUserInterface $user + */ + public function removeUser(RolloutUserInterface $user) + { + if (($key = array_search($user->getRolloutIdentifier(), $this->users)) !== false) { + unset($this->users[$key]); + } + } + + /** + * @return array + */ + public function getUsers() + { + return $this->users; + } + + /** + * @param string $group + */ + public function addGroup($group) + { + if (!in_array($group, $this->groups)) { + $this->groups[] = $group; + } + } + + /** + * @param $group + */ + public function removeGroup($group) + { + if (($key = array_search($group, $this->groups)) !== false) { + unset($this->groups[$key]); + } + } + + /** + * @return array + */ + public function getGroups() + { + return $this->groups; + } + + /** + * Clear the feature of all configuration + */ + public function clear() + { + $this->groups = array(); + $this->users = array(); + $this->percentage = 0; + } + + /** + * Is the feature active? + * + * @param Rollout $rollout + * @param RolloutUserInterface|null $user + * @return bool + */ + public function isActive(Rollout $rollout, RolloutUserInterface $user = null) + { + if (null == $user) { + return $this->percentage == 100; + } + + return $this->isUserInPercentage($user) || $this->isUserInActiveUsers($user) || $this->isUserInActiveGroup($user, $rollout); + } + + /** + * @return array + */ + public function toArray() + { + return array( + 'percentage' => $this->percentage, + 'groups' => $this->groups, + 'users' => $this->users, + ); + } + + /** + * @param RolloutUserInterface $user + * @return bool + */ + private function isUserInPercentage(RolloutUserInterface $user) + { + return crc32($user->getRolloutIdentifier()) % 100 < $this->percentage; + } + + /** + * @param RolloutUserInterface $user + * @return mixed + */ + private function isUserInActiveUsers(RolloutUserInterface $user) + { + return in_array($user->getRolloutIdentifier(), $this->users); + } + + /** + * @param RolloutUserInterface $user + * @param Rollout $rollout + * @return bool + */ + private function isUserInActiveGroup(RolloutUserInterface $user, Rollout $rollout) + { + foreach ($this->groups as $group) { + if ($rollout->isActiveInGroup($group, $user)) { + return true; + } + } + + return false; + } +} diff --git a/src/Rollout.php b/src/Rollout.php new file mode 100644 index 0000000..07b9383 --- /dev/null +++ b/src/Rollout.php @@ -0,0 +1,239 @@ + + */ +class Rollout +{ + /** + * @var StorageInterface + */ + private $storage; + + /** + * @var array + */ + private $groups; + + /** + * @param StorageInterface $storage + */ + public function __construct(StorageInterface $storage) + { + $this->storage = $storage; + $this->groups = array( + 'all' => function(RolloutUserInterface $user) { return $user !== null; } + ); + } + + /** + * @param string $feature + */ + public function activate($feature) + { + $feature = $this->get($feature); + if ($feature) { + $feature->setPercentage(100); + $this->save($feature); + } + } + + + /** + * @param string $feature + */ + public function deactivate($feature) + { + $feature = $this->get($feature); + if ($feature) { + $feature->clear(); + $this->save($feature); + } + } + + /** + * @param string $feature + * @param string $group + */ + public function activateGroup($feature, $group) + { + $feature = $this->get($feature); + if ($feature) { + $feature->addGroup($group); + $this->save($feature); + } + } + + /** + * @param string $feature + * @param string $group + */ + public function deactivateGroup($feature, $group) + { + $feature = $this->get($feature); + if ($feature) { + $feature->removeGroup($group); + $this->save($feature); + } + } + + /** + * @param string $feature + * @param RolloutUserInterface $user + */ + public function activateUser($feature, RolloutUserInterface $user) + { + $feature = $this->get($feature); + if ($feature) { + $feature->addUser($user); + $this->save($feature); + } + } + + /** + * @param string $feature + * @param RolloutUserInterface $user + */ + public function deactivateUser($feature, RolloutUserInterface $user) + { + $feature = $this->get($feature); + if ($feature) { + $feature->removeUser($user); + $this->save($feature); + } + } + + /** + * @param string $group + * @param callable $closure + */ + public function defineGroup($group, \Closure $closure) + { + $this->groups[$group] = $closure; + } + + /** + * @param string $feature + * @param RolloutUserInterface|null $user + * @return bool + */ + public function isActive($feature, RolloutUserInterface $user = null) + { + $feature = $this->get($feature); + + return $feature ? $feature->isActive($this, $user) : false; + } + + /** + * @param string $feature + * @param integer $percentage + */ + public function activatePercentage($feature, $percentage) + { + $feature = $this->get($feature); + if ($feature) { + $feature->setPercentage($percentage); + $this->save($feature); + } + } + + /** + * @param string $feature + */ + public function deactivatePercentage($feature) + { + $feature = $this->get($feature); + if ($feature) { + $feature->setPercentage(0); + $this->save($feature); + } + } + + /** + * @param string $group + * @param RolloutUserInterface $user + * @return bool + */ + public function isActiveInGroup($group, RolloutUserInterface $user) + { + if (!isset($this->groups[$group])) { + return false; + } + + $g = $this->groups[$group]; + + return $g && $g($user); + } + + /** + * @param string $feature + * @return Feature + */ + public function get($feature) + { + $settings = $this->storage->get($this->key($feature)); + + if (!empty($settings)) { + $f = new Feature($feature, $settings); + } else { + $f = new Feature($feature); + + $this->save($f); + } + + return $f; + } + + /** + * @return array + */ + public function features() + { + $content = $this->storage->get($this->featuresKey()); + + if (!empty($content)) { + return explode(',', $content); + } + + return array(); + } + + /** + * @param string $name + * @return string + */ + private function key($name) + { + return 'feature:' . $name; + } + + /** + * @return string + */ + private function featuresKey() + { + return 'feature:__features__'; + } + + /** + * @param Feature $feature + */ + private function save(Feature $feature) + { + $name = $feature->getName(); + $this->storage->set($this->key($name), $feature->serialize()); + + $features = $this->features(); + if (!in_array($name, $features)) { + $features[] = $name; + } + $this->storage->set($this->featuresKey(), implode(',', $features)); + } +} diff --git a/src/RolloutUserInterface.php b/src/RolloutUserInterface.php new file mode 100644 index 0000000..a8ab1c7 --- /dev/null +++ b/src/RolloutUserInterface.php @@ -0,0 +1,18 @@ + + */ +interface RolloutUserInterface +{ + /** + * @return string + */ + public function getRolloutIdentifier(); +} diff --git a/src/Storage/ArrayStorage.php b/src/Storage/ArrayStorage.php new file mode 100644 index 0000000..2d5c5f1 --- /dev/null +++ b/src/Storage/ArrayStorage.php @@ -0,0 +1,36 @@ + + */ +class ArrayStorage implements StorageInterface +{ + /** + * @var array + */ + private $storage = array(); + + /** + * @param string $key + * @return mixed|null + */ + public function get($key) + { + return isset($this->storage[$key]) ? $this->storage[$key] : null; + } + + /** + * @param string $key + * @param mixed $value + */ + public function set($key, $value) + { + $this->storage[$key] = $value; + } + +} diff --git a/src/Storage/DoctrineCacheStorageAdapter.php b/src/Storage/DoctrineCacheStorageAdapter.php new file mode 100644 index 0000000..e0be0f4 --- /dev/null +++ b/src/Storage/DoctrineCacheStorageAdapter.php @@ -0,0 +1,47 @@ + + */ +class DoctrineCacheStorageAdapter implements StorageInterface +{ + /** + * @var Cache + */ + private $cache; + + /** + * @param Cache $cache + */ + public function __construct(Cache $cache) + { + $this->cache = $cache; + } + + /** + * @param string $key + * @return mixed|null Null if the value is not found + */ + public function get($key) + { + return $this->cache->fetch($key); + } + + /** + * @param string $key + * @param mixed $value + */ + public function set($key, $value) + { + $this->cache->save($key, $value); + } +} diff --git a/src/Storage/StorageInterface.php b/src/Storage/StorageInterface.php new file mode 100644 index 0000000..0327718 --- /dev/null +++ b/src/Storage/StorageInterface.php @@ -0,0 +1,24 @@ + + */ +interface StorageInterface +{ + /** + * @param string $key + * @return mixed|null Null if the value is not found + */ + public function get($key); + + /** + * @param string $key + * @param mixed $value + */ + public function set($key, $value); +} diff --git a/tests/RolloutTest.php b/tests/RolloutTest.php new file mode 100644 index 0000000..ecba8dc --- /dev/null +++ b/tests/RolloutTest.php @@ -0,0 +1,277 @@ + + */ +class RolloutTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var Rollout + */ + private $rollout; + + protected function setUp() + { + $this->rollout = new Rollout(new ArrayStorage()); + } + + public function testActiveForBlockGroup() + { + // When a group is activated + $this->rollout->defineGroup('fivesonly', function(RolloutUserInterface $user) { return $user->getRolloutIdentifier() == 5; }); + $this->rollout->activateGroup('chat', 'fivesonly'); + + // the feature is active for users for which the callback evaluates as true + $this->assertTrue($this->rollout->isActive('chat', new User(5))); + + // is not active for users for which the callback evalutates to false + $this->assertFalse($this->rollout->isActive('chat', new User(1))); + + // is not active if a group is found in storage, but not defined in the rollout + $this->rollout->activateGroup('chat', 'fake'); + $this->assertFalse($this->rollout->isActive('chat', new User(1))); + } + + public function testDefaultAllGroup() + { + // the default all group + $this->rollout->activateGroup('chat', 'all'); + + // evaluates to true no matter what + $this->assertTrue($this->rollout->isActive('chat', new User(0))); + } + + public function testDeactivatingAGroup() + { + $this->rollout->defineGroup('fivesonly', function(RolloutUserInterface $user) { return $user->getRolloutIdentifier() == 5; }); + $this->rollout->activateGroup('chat', 'all'); + $this->rollout->activateGroup('chat', 'some'); + $this->rollout->activateGroup('chat', 'fivesonly'); + $this->rollout->deactivateGroup('chat', 'all'); + $this->rollout->deactivateGroup('chat', 'some'); + + // deactivates the rules for that group + $this->assertFalse($this->rollout->isActive('chat', new User(10))); + + // leaves the other groups active + $this->assertContains('fivesonly', $this->rollout->get('chat')->getGroups()); + $this->assertCount(1, $this->rollout->get('chat')->getGroups()); + } + + public function testDeactivatingAFeatureCompletely() + { + $this->rollout->defineGroup('fivesonly', function(RolloutUserInterface $user) { return $user->getRolloutIdentifier() === 5; }); + $this->rollout->activateGroup('chat', 'all'); + $this->rollout->activateGroup('chat', 'fivesonly'); + $this->rollout->activateUser('chat', new User(51)); + $this->rollout->activatePercentage('chat', 100); + $this->rollout->activate('chat'); + $this->rollout->deactivate('chat'); + + // it should remove all of the groups + $this->assertFalse($this->rollout->isActive('chat', new User(0))); + + // it should remove all of the users + $this->assertFalse($this->rollout->isActive('chat', new User(51))); + + // it should remove the percentage + $this->assertFalse($this->rollout->isActive('chat', new User(24))); + + // it should be removed globally + $this->assertFalse($this->rollout->isActive('chat')); + } + + public function testActivatingASpecificUser() + { + $this->rollout->activateUser('chat', new User(42)); + + // it should be active for that user + $this->assertTrue($this->rollout->isActive('chat', new User(42))); + + // it remains inactive for other users + $this->assertFalse($this->rollout->isActive('chat', new User(24))); + } + + public function testActivatingASpecificUserWithStringId() + { + $this->rollout->activateUser('chat', new User('user-72')); + + // it should be active for that user + $this->assertTrue($this->rollout->isActive('chat', new User('user-72'))); + + // it remains inactive for other users + $this->assertFalse($this->rollout->isActive('chat', new User('user-12'))); + } + + public function testDeactivatingASpecificUser() + { + $this->rollout->activateUser('chat', new User(42)); + $this->rollout->activateUser('chat', new User(4242)); + $this->rollout->activateUser('chat', new User(24)); + $this->rollout->deactivateUser('chat', new User(42)); + $this->rollout->deactivateUser('chat', new User('4242')); + + // that user should no longer be active + $this->assertFalse($this->rollout->isActive('chat', new User(42))); + + // it remains active for other users + $users = $this->rollout->get('chat')->getUsers(); + $this->assertCount(1, $users); + $this->assertEquals(24, $users[0]); + } + + public function testActivatingAFeatureGlobally() + { + $this->rollout->activate('chat'); + + // it should activate the feature + $this->assertTrue($this->rollout->isActive('chat')); + } + + public function testActivatingAFeatureForPercentageOfUsers() + { + $this->rollout->activatePercentage('chat', 20); + + $activated = []; + foreach (range(1, 120) as $id) { + if ($this->rollout->isActive('chat', new User($id))) { + $activated[] = true; + } + } + + // it should activate the feature for a percentage of users + $this->assertLessThanOrEqual(21, count($activated)); + $this->assertGreaterThanOrEqual(19, count($activated)); + } + + public function testActivatingAFeatureForPercentageOfUsers2() + { + $this->rollout->activatePercentage('chat', 20); + + $activated = []; + foreach (range(1, 200) as $id) { + if ($this->rollout->isActive('chat', new User($id))) { + $activated[] = true; + } + } + + // it should activate the feature for a percentage of users + $this->assertLessThanOrEqual(45, count($activated)); + $this->assertGreaterThanOrEqual(35, count($activated)); + } + + public function testActivatingAFeatureForPercentageOfUsers3() + { + $this->rollout->activatePercentage('chat', 5); + + $activated = []; + foreach (range(1, 100) as $id) { + if ($this->rollout->isActive('chat', new User($id))) { + $activated[] = true; + } + } + + // it should activate the feature for a percentage of users + $this->assertLessThanOrEqual(7, count($activated)); + $this->assertGreaterThanOrEqual(3, count($activated)); + } + + public function testActivatingAFeatureForAGroupAsAString() + { + $this->rollout->defineGroup('admins', function(RolloutUserInterface $user) { return $user->getRolloutIdentifier() == 5; }); + $this->rollout->activateGroup('chat', 'admins'); + + // the feature is active for users for which the block is true + $this->assertTrue($this->rollout->isActive('chat', new User(5))); + + // the feature is not active for users for which the block evaluates to false + $this->assertFalse($this->rollout->isActive('chat', new User(1))); + } + + public function testDeactivatingThePercentageOfUsers() + { + $this->rollout->activatePercentage('chat', 100); + $this->rollout->deactivatePercentage('chat'); + + // it becomes inactive for all users + $this->assertFalse($this->rollout->isActive('chat', new User(24))); + } + + public function testDeactivatingTheFeatureGlobally() + { + $this->rollout->activate('chat'); + $this->rollout->deactivate('chat'); + + // inactive feature + $this->assertFalse($this->rollout->isActive('chat')); + } + + public function testKeepsAListOfFeatures() + { + // saves the feature + $this->rollout->activate('chat'); + $this->assertContains('chat', $this->rollout->features()); + + // does not contain doubles + $this->rollout->activate('chat'); + $this->rollout->activate('chat'); + $this->assertCount(1, $this->rollout->features()); + } + + public function testGet() + { + $this->rollout->activatePercentage('chat', 10); + $this->rollout->activateGroup('chat', 'caretakers'); + $this->rollout->activateGroup('chat', 'greeters'); + $this->rollout->activate('signup'); + $this->rollout->activateUser('chat', new User(42)); + + // it should return the feature object + $feature = $this->rollout->get('chat'); + $this->assertContains('caretakers', $feature->getGroups()); + $this->assertContains('greeters', $feature->getGroups()); + $this->assertEquals(10, $feature->getPercentage()); + $this->assertContains(42, $feature->getUsers()); + $this->assertEquals(array('groups' => array('caretakers', 'greeters'), 'percentage' => 10, 'users' => array('42')), $feature->toArray()); + + $feature = $this->rollout->get('signup'); + $this->assertEmpty($feature->getGroups()); + $this->assertEmpty($feature->getUsers()); + $this->assertEquals(100, $feature->getPercentage()); + } +} + + +/** + * @author Richard Fullmer + */ +class User implements RolloutUserInterface +{ + /** + * @var string + */ + private $id; + + /** + * @param string $id + */ + public function __construct($id) + { + $this->id = $id; + } + + /** + * @return string + */ + public function getRolloutIdentifier() + { + return $this->id; + } +}