Skip to content

Commit

Permalink
Merge pull request #19 from yahoo/cache
Browse files Browse the repository at this point in the history
Add `cache` option to improve serialization performance
  • Loading branch information
ericf committed Jan 23, 2014
2 parents 4ad1f8f + 34d3971 commit 2215823
Show file tree
Hide file tree
Showing 7 changed files with 347 additions and 53 deletions.
29 changes: 29 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
Express State Change History
============================

NEXT
----

* __[!]__ Deprecated `expose( obj, namespace, local )` API signature, the third
argument is now `options`, use: `expose( obj, namespace, {local: local})`. The
deprecated signature will be removed in a future release, and logs a warning
when it is used.

* Added `{cache: true}` option that signals Express State to eagerly serialize
unchanging data and reuse the result to *greatly* improve performance of
repeated `toString()` calls ([#19][]).

* Fixed issue with `app` <-- `res` exposed data inheritance. Previously, the
exposed data a `res` would inherit from the `app` was locked to the state of
the exposed app-scope data at the time of the request. Now, if new data is
exposed at the app-scope during a request, it's properly inherited by the
request-scoped data.

* Added benchmark tests. They can be run using: `npm run benchmark`. Real world
fixture data is used from Photos Near Me and Yahoo Tech.

* Tweaked `toString()` serialization process to gather low-hanging performance
fruit ([#18][]).


[#18]: https://github.com/yahoo/express-state/issues/18
[#19]: https://github.com/yahoo/express-state/issues/19


1.0.3 (2013-12-04)
------------------

Expand Down
56 changes: 48 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ following is a list of features highlighting differences when compared with
calls will be serialized. Serialization can happen at anytime, on demand, by
calling the `toString()` method on `state` "locals" objects.

When data is not going to change the `{cache: true}` option can be set to
eagerly serialize exposed objects, making repeated `toString()` calls more efficient.

- **Explicit extension of each Express app:** Express State's functionality has
to be explicitly added to an Express app via the exported `extend()` function.
This prevents problems in complex apps where multiple versions of Express
Expand Down Expand Up @@ -178,6 +181,31 @@ app.use(function (req, res, next) {
The client-side JavaScript code can now add the `X-CSRF-Token` HTTP header with
the value at `MY_APP.CSRF_TOKEN` to all XHRs it makes to the server.

#### Increase Performance of Unchanging or Static Data

It's common to expose app-scoped data which *will not change* during the
lifecycle of the Express app instance. To improve per-request performance, this
unchanging/static data can be eagerly serialized and cached by setting the
__`{cache: true}`__ option:

```js
var CONFIG = {
hostname : 'example.com',
someOther: 'constant value'
};

app.expose(CONFIG, 'MY_APP.config', {cache: true});
```

Setting this option allows Express State to optimize the serialization process
by keeping the serialized value around and re-using it every time the
`toString()` method is invoked (which happens for every request.)

**Note:** When a large amount of data needs to be exposed to the client-side, it
is recommended to come up with a strategy where _all_ data which is common to
most/every request be exposed at the app-scope with the `{cache: true}` option
set.

#### Untrusted User Input

**Always escape untrusted user input to protected against XSS attacks!**
Expand Down Expand Up @@ -247,9 +275,10 @@ a specific namespace on the client.
### Overriding Exposed Values

Objects that are exposed through either `expose()` method are stored by
reference, and serialization is done lazily. This means the objects are still
"live" after they've been exposed. An object can be exposed early during the
life cycle of a request and updated up until the response is sent.
reference, and serialization is done lazily (unless the `{cache: true}` option
was set). This means the objects are still "live" after they've been exposed. An
object can be exposed early during the life cycle of a request and updated up
until the response is sent.

The following is a contrived example, but shows how values can be overridden at
any time and at any scope:
Expand Down Expand Up @@ -473,9 +502,9 @@ See [Extending an Express App][] for more details.

### Methods

#### `app.expose (obj, [namespace], [local])`
#### `app.expose (obj, [namespace], [options])`

#### `res.expose (obj, [namespace], [local])`
#### `res.expose (obj, [namespace], [options])`

The two `expose()` methods behave the same, the only difference being what scope
the data is exposed, either the app's or at the request's scope.
Expand All @@ -495,9 +524,20 @@ input is _always_ escaped before it passed to this method.
This namespace will be prefixed with any configured root namespace unless it
already contains the root namespace or starts with `"window."`.

* `[local]`: Optional string name of the "locals" property on which to expose
the `obj`. This is used to specify a locals property other than the
configured or default (`"state"`) one.
* `[options]`: Options which can be specified as either the second or third
argument to this method, and may contain the following:

* `[cache]`: Optional boolean to signal that it's safe to cache the
serialized form of `obj` because it won't change during the lifecycle of
the `app` or `res` (depending on which `expose()` method is invoked.) The eagerly serialized result is cached to greatly optimize to speed of
repeated calls to the `toString()` method.

* `[local]`: Optional string name of the "locals" property on which to
expose the `obj`. This is used to specify a locals property other than the
configured or default (`"state"`) one.

* `[namespace]`: Used to specify a `namespace` (described above) when
`options` is passed as the second argument to this method.

**Note:** A `TypeError` will be thrown if a native built-in function is being
serialized, like the `Number` constructor. Native built-ins should be called in
Expand Down
27 changes: 23 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,33 @@ function extendApp(app) {
return app;
}

function expose(obj, namespace, local) {
function expose(obj, namespace, options) {
/* jshint validthis:true */

var app = this.app || this,
appLocals = this.app && this.app.locals,
locals = this.locals,
rootNamespace = app.get('state namespace') || exports.namespace,
exposed, type;
local, exposed, type;

// Massage arguments to support the following signatures:
// expose( obj [[, namespace [, options]] | [, options]] )
// expose( obj [, namespace [, local]] )
if (namespace && typeof namespace === 'object') {
options = namespace;
namespace = options.namespace;
local = options.local;
} else if (options && typeof options === 'string') {
local = options;
options = null;

// Warn about deprecated API signature:
// expose( obj [, namespace [, local]] )
console.warn('(express-state) warning: ' +
'`expose( obj, namespace, local)` signature has been deprecated.');
} else {
local = options && options.local;
}

if (!local) {
local = app.get('state local') || exports.local;
Expand All @@ -49,7 +68,7 @@ function expose(obj, namespace, local) {
// Only get the keys of enumerable objects.
if ((type === 'object' || type === 'function') && obj !== null) {
Object.keys(obj).forEach(function (key) {
exposed.add(key, obj[key]);
exposed.add(key, obj[key], options);
});
}

Expand All @@ -66,5 +85,5 @@ function expose(obj, namespace, local) {
namespace = rootNamespace;
}

exposed.add(namespace, obj);
exposed.add(namespace, obj, options);
}
109 changes: 85 additions & 24 deletions lib/exposed.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@ module.exports = Exposed;

function Exposed() {
Object.defineProperties(this, {
// Brand.
'@exposed': {value: true},
// Brand with constructor.
'@exposed': {value: Exposed},

// Defines a "hidden" property which holds an ordered list of exposed
// Defines a "hidden" property that holds an ordered list of exposed
// namespaces. When new namespaces are exposed, existing ones are
// examined and removed if they are to become noops.
'__namespaces__': {value: []}
// examined and removed if they would end up being noops.
__namespaces__: {value: []},

// Defines a "hidden" property that stores serializations of data by
// namespace that was exposed and deemed cacheable; e.g. won't be
// changing. This allows the `toString()` method to run *much* faster.
__serialized__: {value: {}}
});
}

Expand All @@ -21,14 +26,14 @@ Exposed.create = function (exposed) {
return new Exposed();
}

// Inherit current namespaces state from the parent exposed instance.
var namespaces = exposed.__namespaces__.concat();

// Creates a new exposed object with the specified `exposed` instance as
// its prototype. This allows the new object to inherit from, *and* shadow
// the existing object.
// Creates a new exposed object with the specified `exposed` instance as its
// prototype. This allows the new object to inherit from, *and* shadow the
// existing object. A prototype relationship is also setup for the
// serialized cached state, aggregation of applicable namespaces happens at
// `toString()` time.
return Object.create(exposed, {
__namespaces__: {value: namespaces}
__namespaces__: {value: []},
__serialized__: {value: Object.create(exposed.__serialized__)}
});
};

Expand All @@ -38,30 +43,46 @@ Exposed.isExposed = function (obj) {

// TODO: Should this be a static method so it doesn't reserve the "add"
// namespace on all Exposed instances?
Exposed.prototype.add = function (namespace, value) {
Exposed.prototype.add = function (namespace, value, options) {
var nsRegex = new RegExp('^' + namespace + '(?:$|\\..+)'),
namespaces = this.__namespaces__,
oldNamespaces = namespaces.filter(nsRegex.test.bind(nsRegex));
oldNamespaces = namespaces.filter(nsRegex.test.bind(nsRegex)),
serialized = this.__serialized__;

// Removes previously exposed namespaces and values which no longer apply
// and have become noops.
// Removes previously exposed namespaces, values, and serialized state which
// no longer apply and have become noops.
oldNamespaces.forEach(function (namespace) {
delete this[namespace];
namespaces.splice(namespaces.indexOf(namespace), 1);
delete serialized[namespace];
delete this[namespace];
}, this);

// Stores the new exposed namespace and its current value.
namespaces.push(namespace);
this[namespace] = value;

// When it's deemed safe to cache the serialized form of the `value` because
// it won't change, run the serialization process once, eagerly. The result
// is cached to greatly optimize to speed of the `toString()` method.
if (options && options.cache) {
serialized[namespace] = serialize(value);
}
};

Exposed.prototype.toString = function () {
var rendered = {},
namespaces = '',
data = '';
data = '',
serialized = this.__serialized__;

// Values are exposed at their namespace in the order they were `add()`ed.
this.__namespaces__.forEach(function (namespace) {
// This gathers all the namespaces which are logically applicable by walking
// up the prototype chain. Namespaces are initialized and their values are
// assigned to them.
//
// **Note:** children shadow parents, and cached serialized values are used
// when available.
this._getApplicableNamespaces().forEach(function (namespace) {
var parts = namespace.split('.'),
leafPart = parts.pop(),
nsPart = 'root';
Expand All @@ -78,11 +99,11 @@ Exposed.prototype.toString = function () {
}
}

// Renders the JavaScript to assign the serialized value to the
// namespace. These assignments are done in the order in which they were
// exposed via the `add()` method.
nsPart += '.' + leafPart;
data += nsPart + ' = ' + serialize(this[namespace]) + ';\n';
// Renders the JavaScript to assign the serialized value (either cached
// or created now) to the namespace. These assignments are done in the
// order in which they were exposed via the `add()` method.
data += (nsPart + '.' + leafPart) + ' = ' +
(serialized[namespace] || serialize(this[namespace])) + ';\n';
}, this);

return (
Expand All @@ -93,3 +114,43 @@ Exposed.prototype.toString = function () {
data +
'}(this));\n');
};

Exposed.prototype._getApplicableNamespaces = function () {
var namespaces = this.__namespaces__,
proto = Object.getPrototypeOf(this);

// A namespace is only applicable when there are no existing namespaces in
// the collection which would logically "trump" it; e.g.:
//
// var namespaces = ['foo'];
// isApplicable('foo.bar'); // => false
// isApplicable('bar'); // => true
//
// This deduping keeps the set of exposed values as small as possible by
// not including items which will be logically overridden by others.
function isApplicable(namespace) {
if (namespaces.length === 0) {
return true;
}

return !namespaces.some(function (ns) {
var nsRegex = new RegExp('^' + ns + '(?:$|\\..+)');
return nsRegex.test(namespace);
});
}

// Walks the prototype chain of `Exposed` instances and collects all
// namespaces which are logically applicable, ordered by: grandparent,
// parent, then child/this. Each instance's namespaces will already be
// ordered and logically deduped by every `add()` call.
while (Exposed.isExposed(proto)) {
if (proto.__namespaces__.length) {
namespaces = proto.__namespaces__.filter(isApplicable)
.concat(namespaces);
}

proto = Object.getPrototypeOf(proto);
}

return namespaces;
};
Loading

0 comments on commit 2215823

Please sign in to comment.