From c71c796a66359e436cd87632fa23eed5fb5d4fff Mon Sep 17 00:00:00 2001 From: Takashi Matsuo Date: Tue, 1 Nov 2016 05:52:05 -0700 Subject: [PATCH] Adding a custom session handler with Cloud Datastore. (#213) * Adding a custom session handler with Cloud Datastore. * Added a constructor * Unit test * Use $sessionName for the kind * Fixed timing bug in the test * Fixed more timing bug in the tests * Use session.save_path for namespace. * Use transaction * Throw \InvalidArgumentException instead of replacing silently. * Changed to E_USER_WARNING and added some docs. * Use Prophecy * Use shouldBeCalledTimes * Added a docblock * Added an example for handling errors. * Added prefered use case in the docblock * Doc tweak * TYPO * Docs tweak * Use ServiceBuilder * code review * Fixed the @see tags --- src/Datastore/DatastoreSessionHandler.php | 298 +++++++++++ .../Datastore/DatastoreSessionHandlerTest.php | 473 ++++++++++++++++++ 2 files changed, 771 insertions(+) create mode 100644 src/Datastore/DatastoreSessionHandler.php create mode 100644 tests/Datastore/DatastoreSessionHandlerTest.php diff --git a/src/Datastore/DatastoreSessionHandler.php b/src/Datastore/DatastoreSessionHandler.php new file mode 100644 index 000000000000..e4248e27708f --- /dev/null +++ b/src/Datastore/DatastoreSessionHandler.php @@ -0,0 +1,298 @@ +datastore(); + * // or just $datastore = new \Google\Cloud\Datastore\DatastoreClient(); + * + * $handler = new DatastoreSessionHandler($datastore); + * + * session_set_save_handler($handler, true); + * session_save_path('sessions'); + * session_start(); + * + * // Then read and write the $_SESSION array. + * + * ``` + * + * The above example automatically writes the session data. It's handy, but + * the code doesn't stop even if it fails to write the session data, because + * the `write` happens when the code exits. If you want to know the session + * data is correctly written to the Datastore, you need to call + * `session_write_close()` explicitly and then handle `E_USER_WARNING` + * properly like the following example. + * + * Example with error handling: + * + * ``` + * use Google\Cloud\ServiceBuilder; + * + * $cloud = new ServiceBuilder(); + * $datastore = $cloud->datastore(); + * // or just $datastore = new \Google\Cloud\Datastore\DatastoreClient(); + * + * $handler = new DatastoreSessionHandler($datastore); + * session_set_save_handler($handler, true); + * session_save_path('sessions'); + * session_start(); + * + * // Then read and write the $_SESSION array. + * + * function handle_session_error($errNo, $errStr, $errFile, $errLine) { + * # We throw an exception here, but you can do whatever you need. + * throw new Exception("$errStr in $errFile on line $errLine", $errNo); + * } + * set_error_handler('handle_session_error', E_USER_WARNING); + * // If `write` fails for any reason, an exception will be thrown. + * session_write_close(); + * restore_error_handler(); + * // You can still read the $_SESSION array after closing the session. + * ``` + * @see http://php.net/manual/en/class.sessionhandlerinterface.php SessionHandlerInterface + */ +class DatastoreSessionHandler implements SessionHandlerInterface +{ + const DEFAULT_GC_LIMIT = 0; + /* + * @see https://cloud.google.com/datastore/docs/reference/rpc/google.datastore.v1#google.datastore.v1.PartitionId + */ + const NAMESPACE_ALLOWED_PATTERN = '/^[A-Za-z\d\.\-_]{0,100}$/'; + const NAMESPACE_RESERVED_PATTERN = '/^__.*__$/'; + + /* @var int */ + private $gcLimit; + + /* @var DatastoreClient */ + private $datastore; + + /* @var string */ + private $kind; + + /* @var string */ + private $namespaceId; + + /* @var Transaction */ + private $transaction; + + /** + * Create a custom session handler backed by Cloud Datastore. + * + * @param DatastoreClient $datastore Datastore client. + * @param int $gcLimit [optional] A number of entities to delete in the + * garbage collection. Defaults to 0 which means it does nothing. + * The value larger than 1000 will be cut down to 1000. + */ + public function __construct( + DatastoreClient $datastore, + $gcLimit = self::DEFAULT_GC_LIMIT + ) { + $this->datastore = $datastore; + // Cut down to 1000 + $this->gcLimit = min($gcLimit, 1000); + } + + /** + * Start a session, by creating a transaction for the later `write`. + * + * @param string $savePath The value of `session.save_path` setting will be + * used here. It will use this value as the Datastore namespaceId. + * @param string $sessionName The value of `session.name` setting will be + * used here. It will use this value as the Datastore kind. + * @return bool + */ + public function open($savePath, $sessionName) + { + $this->kind = $sessionName; + if (preg_match(self::NAMESPACE_ALLOWED_PATTERN, $savePath) !== 1 || + preg_match(self::NAMESPACE_RESERVED_PATTERN, $savePath) === 1) { + throw new InvalidArgumentException( + sprintf('The given save_path "%s" not allowed', $savePath) + ); + } + $this->namespaceId = $savePath; + $this->transaction = $this->datastore->transaction(); + return true; + } + + /** + * Just return true for this implementation. + */ + public function close() + { + return true; + } + + /** + * Read the session data from Cloud Datastore. + */ + public function read($id) + { + try { + $key = $this->datastore->key( + $this->kind, + $id, + ['namespaceId' => $this->namespaceId] + ); + $entity = $this->transaction->lookup($key); + if ($entity !== null && isset($entity['data'])) { + return $entity['data']; + } + } catch (Exception $e) { + trigger_error( + sprintf('Datastore lookup failed: %s', $e->getMessage()), + E_USER_WARNING + ); + } + return ''; + } + + /** + * Write the session data to Cloud Datastore. + */ + public function write($id, $data) + { + try { + $key = $this->datastore->key( + $this->kind, + $id, + ['namespaceId' => $this->namespaceId] + ); + $entity = $this->datastore->entity( + $key, + [ + 'data' => $data, + 't' => time() + ] + ); + $this->transaction->upsert($entity); + $this->transaction->commit(); + } catch (Exception $e) { + trigger_error( + sprintf('Datastore upsert failed: %s', $e->getMessage()), + E_USER_WARNING + ); + return false; + } + return true; + } + + /** + * Delete the session data from Cloud Datastore. + */ + public function destroy($id) + { + try { + $key = $this->datastore->key( + $this->kind, + $id, + ['namespaceId' => $this->namespaceId] + ); + $this->transaction->delete($key); + $this->transaction->commit(); + } catch (Exception $e) { + trigger_error( + sprintf('Datastore delete failed: %s', $e->getMessage()), + E_USER_WARNING + ); + return false; + } + return true; + } + + /** + * Delete the old session data from Cloud Datastore. + */ + public function gc($maxlifetime) + { + if ($this->gcLimit === 0) { + return true; + } + try { + $query = $this->datastore->query() + ->kind($this->kind) + ->filter('t', '<', time() - $maxlifetime) + ->order('t') + ->keysOnly() + ->limit($this->gcLimit); + $result = $this->datastore->runQuery( + $query, + ['namespaceId' => $this->namespaceId] + ); + $keys = []; + /* @var Entity $e */ + foreach ($result as $e) { + $keys[] = $e->key(); + } + if (!empty($keys)) { + $this->datastore->deleteBatch($keys); + } + } catch (Exception $e) { + trigger_error( + sprintf('Session gc failed: %s', $e->getMessage()), + E_USER_WARNING + ); + return false; + } + return true; + } +} diff --git a/tests/Datastore/DatastoreSessionHandlerTest.php b/tests/Datastore/DatastoreSessionHandlerTest.php new file mode 100644 index 000000000000..44db872a4f62 --- /dev/null +++ b/tests/Datastore/DatastoreSessionHandlerTest.php @@ -0,0 +1,473 @@ +datastore = $this->prophesize(DatastoreClient::class); + $this->transaction = $this->prophesize(Transaction::class); + } + + public function testOpen() + { + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $ret = $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $this->assertTrue($ret); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testOpenNotAllowed() + { + $this->datastore->transaction() + ->shouldNotBeCalled() + ->willReturn($this->transaction->reveal()); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $datastoreSessionHandler->open('/tmp/sessions', self::KIND); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testOpenReserved() + { + $this->datastore->transaction() + ->shouldNotBeCalled() + ->willReturn($this->transaction->reveal()); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $datastoreSessionHandler->open('__RESERVED__', self::KIND); + } + + public function testClose() + { + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $ret = $datastoreSessionHandler->close(); + $this->assertTrue($ret); + } + + public function testReadNothing() + { + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $key = new Key('projectid'); + $key->pathElement(self::KIND, 'sessionid'); + $this->datastore->key( + self::KIND, + 'sessionid', + ['namespaceId' => self::NAMESPACE_ID] + ) + ->shouldBeCalledTimes(1) + ->willReturn($key); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->read('sessionid'); + + $this->assertEquals('', $ret); + } + + public function testReadWithException() + { + \PHPUnit_Framework_Error_Warning::$enabled = FALSE; + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $this->datastore->key( + self::KIND, + 'sessionid', + ['namespaceId' => self::NAMESPACE_ID] + ) + ->shouldBeCalledTimes(1) + ->willThrow((new Exception())); + + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->read('sessionid'); + + $this->assertEquals('', $ret); + } + + public function testReadEntity() + { + $key = new Key('projectid'); + $key->pathElement(self::KIND, 'sessionid'); + $entity = new Entity($key, ['data' => 'sessiondata']); + $this->transaction->lookup($key) + ->shouldBeCalledTimes(1) + ->willReturn($entity); + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $this->datastore->key( + self::KIND, + 'sessionid', + ['namespaceId' => self::NAMESPACE_ID] + ) + ->shouldBeCalledTimes(1) + ->willReturn($key); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->read('sessionid'); + + $this->assertEquals('sessiondata', $ret); + } + + public function testWrite() + { + $data = 'sessiondata'; + $key = new Key('projectid'); + $key->pathElement(self::KIND, 'sessionid'); + $entity = new Entity($key, ['data' => $data]); + $this->transaction->upsert($entity) + ->shouldBeCalledTimes(1); + $this->transaction->commit() + ->shouldBeCalledTimes(1); + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $this->datastore->key( + self::KIND, + 'sessionid', + ['namespaceId' => self::NAMESPACE_ID] + ) + ->shouldBeCalledTimes(1) + ->willReturn($key); + $that = $this; + $this->datastore->entity($key, Argument::type('array')) + ->will(function($args) use ($that, $key, $entity) { + $that->assertEquals($key, $args[0]); + $that->assertEquals('sessiondata', $args[1]['data']); + $that->assertInternalType('int', $args[1]['t']); + $that->assertTrue(time() >= $args[1]['t']); + // 2 seconds grace period should be enough + $that->assertTrue(time() - $args[1]['t'] <= 2); + return $entity; + }); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->write('sessionid', $data); + + $this->assertEquals(true, $ret); + } + + public function testWriteWithException() + { + \PHPUnit_Framework_Error_Warning::$enabled = FALSE; + $data = 'sessiondata'; + $key = new Key('projectid'); + $key->pathElement(self::KIND, 'sessionid'); + $entity = new Entity($key, ['data' => $data]); + $this->transaction->upsert($entity) + ->shouldBeCalledTimes(1); + $this->transaction->commit() + ->shouldBeCalledTimes(1) + ->willThrow(new Exception()); + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $this->datastore->key( + self::KIND, + 'sessionid', + ['namespaceId' => self::NAMESPACE_ID] + ) + ->shouldBeCalledTimes(1) + ->willReturn($key); + $that = $this; + $this->datastore->entity($key, Argument::type('array')) + ->will(function($args) use ($that, $key, $entity) { + $that->assertEquals($key, $args[0]); + $that->assertEquals('sessiondata', $args[1]['data']); + $that->assertInternalType('int', $args[1]['t']); + $that->assertTrue(time() >= $args[1]['t']); + // 2 seconds grace period should be enough + $that->assertTrue(time() - $args[1]['t'] <= 2); + return $entity; + }); + + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->write('sessionid', $data); + + $this->assertEquals(false, $ret); + } + + public function testDestroy() + { + $key = new Key('projectid'); + $key->pathElement(self::KIND, 'sessionid'); + $this->transaction->delete($key) + ->shouldBeCalledTimes(1); + $this->transaction->commit() + ->shouldBeCalledTimes(1); + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $this->datastore->key( + self::KIND, + 'sessionid', + ['namespaceId' => self::NAMESPACE_ID] + ) + ->shouldBeCalledTimes(1) + ->willReturn($key); + + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->destroy('sessionid'); + + $this->assertEquals(true, $ret); + } + + public function testDestroyWithException() + { + \PHPUnit_Framework_Error_Warning::$enabled = FALSE; + $key = new Key('projectid'); + $key->pathElement(self::KIND, 'sessionid'); + $this->transaction->delete($key) + ->shouldBeCalledTimes(1); + $this->transaction->commit() + ->shouldBeCalledTimes(1) + ->willThrow(new Exception()); + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $this->datastore->key( + self::KIND, + 'sessionid', + ['namespaceId' => self::NAMESPACE_ID] + ) + ->shouldBeCalledTimes(1) + ->willReturn($key); + + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->destroy('sessionid'); + + $this->assertEquals(false, $ret); + } + + public function testDefaultGcDoesNothing() + { + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $this->datastore->query()->shouldNotBeCalled(); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal() + ); + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->gc(100); + + $this->assertEquals(true, $ret); + } + + public function testGc() + { + $key1 = new Key('projectid'); + $key1->pathElement(self::KIND, 'sessionid1'); + $key2 = new Key('projectid'); + $key2->pathElement(self::KIND, 'sessionid2'); + $entity1 = new Entity($key1); + $entity2 = new Entity($key2); + $query = $this->prophesize(Query::class); + $query->kind(self::KIND) + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + $that = $this; + $query->filter( + Argument::type('string'), + Argument::type('string'), + Argument::type('int') + ) + ->shouldBeCalledTimes(1) + ->will(function($args) use ($that, $query) { + $that->assertEquals('t', $args[0]); + $that->assertEquals('<', $args[1]); + $that->assertInternalType('int', $args[2]); + $diff = time() - $args[2]; + // 2 seconds grace period should be enough + $that->assertTrue($diff <= 102); + $that->assertTrue($diff >= 100); + return $query->reveal(); + }); + $query->order('t') + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + $query->keysOnly() + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + $query->limit(1000) + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $this->datastore->query() + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + $this->datastore->runQuery( + Argument::type(Query::class), + Argument::type('array') + ) + ->shouldBeCalledTimes(1) + ->will( + function($args) + use ($that, $query, $entity1, $entity2) { + $that->assertEquals($query->reveal(), $args[0]); + $that->assertEquals( + ['namespaceId' => self::NAMESPACE_ID], + $args[1] + ); + return [$entity1, $entity2]; + }); + $this->datastore->deleteBatch([$key1, $key2]) + ->shouldBeCalledTimes(1); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal(), + 1000 + ); + + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->gc(100); + + $this->assertEquals(true, $ret); + } + + public function testGcWithException() + { + \PHPUnit_Framework_Error_Warning::$enabled = FALSE; + $key1 = new Key('projectid'); + $key1->pathElement(self::KIND, 'sessionid1'); + $key2 = new Key('projectid'); + $key2->pathElement(self::KIND, 'sessionid2'); + $entity1 = new Entity($key1); + $entity2 = new Entity($key2); + $query = $this->prophesize(Query::class); + $query->kind(self::KIND) + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + $that = $this; + $query->filter( + Argument::type('string'), + Argument::type('string'), + Argument::type('int') + ) + ->shouldBeCalledTimes(1) + ->will(function($args) use ($that, $query) { + $that->assertEquals('t', $args[0]); + $that->assertEquals('<', $args[1]); + $that->assertInternalType('int', $args[2]); + $diff = time() - $args[2]; + // 2 seconds grace period should be enough + $that->assertTrue($diff <= 102); + $that->assertTrue($diff >= 100); + return $query->reveal(); + }); + $query->order('t') + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + $query->keysOnly() + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + $query->limit(1000) + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + + $this->datastore->transaction() + ->shouldBeCalledTimes(1) + ->willReturn($this->transaction->reveal()); + $this->datastore->query() + ->shouldBeCalledTimes(1) + ->willReturn($query->reveal()); + $this->datastore->runQuery( + Argument::type(Query::class), + Argument::type('array') + ) + ->shouldBeCalledTimes(1) + ->will( + function($args) + use ($that, $query, $entity1, $entity2) { + $that->assertEquals($query->reveal(), $args[0]); + $that->assertEquals( + ['namespaceId' => self::NAMESPACE_ID], + $args[1] + ); + return [$entity1, $entity2]; + }); + $this->datastore->deleteBatch([$key1, $key2]) + ->shouldBeCalledTimes(1) + ->willThrow(new Exception()); + $datastoreSessionHandler = new DatastoreSessionHandler( + $this->datastore->reveal(), + 1000 + ); + + $datastoreSessionHandler->open(self::NAMESPACE_ID, self::KIND); + $ret = $datastoreSessionHandler->gc(100); + + $this->assertEquals(false, $ret); + } +}