English |  Español |  Français |  Italiano |  Português |  Русский |  Shqip

Developing an Ember.js Edge

9. Models

Successful single page applications must overcome a number of hurdles, and large among them is the persistence of data. It is, however, not the only challenge. To keep Ember focused and attractive to a wide audience (with very different persistence requirements), the core team long avoided declaring any one data layer "official". In the fall of 2013 that changed, and Ember-Data was declared an official, use-it-in-production persistence library.

Because of the long gap between Ember being production-ready and Ember-Data catching up, several other Ember persistence libraries have emerged. Among the other options are:

  • Ember.Model - From Ember core team member Erik Bryn, this library is comprehensive but maintains a focus on simplicity and extensibility.
  • Ember-Resource - Maintained by Zendesk.
  • Ember RESTless - Matches an earlier version of the Ember-Data API, but simplifies the internals.
  • Emu - Focuses on simplicity, but also has first-class support for pagination and pushed data.
  • Ember.js Persistence Foundation, or EPF - A fork of pre-1.0 Ember-Data maintained by contributor Gordon Hempton for GroupTalent. It addresses many issues that were present in Ember-Data when it was forked, and introduces several powerful new ideas such as sessions.

Beyond this list, there are many Ember projects that forgo a persistence library completely and roll their own data layer. Most famously Discourse, the popular open-source web forum software.

Any JavaScript implementation of data persistence faces similar challenges:

  • Asynchronous actions (such as AJAX requests and responses): How to build an API presenting them to developers, and how to manage the cost of making requests.
  • How to cache fetched record locally.
  • Providing a consistent API over inconsistent persistence endpoints.
  • The serialization of objects into data for a server, and deserialization of server data into objects.
  • How to manage relationships between models.
  • How to resolve model classes.

In detail, this chapter will talk about those challenges and how Ember-Data approaches them. The goal is to give you a solid foundation for working with any persistence layer you choose, as well as give you a taste of the approaches chosen by Ember-Data.

Ember-Data at a Glance

Before getting down to details, a glance at how Ember-Data is used in the context of a simple application is useful. First, an exceedingly trivial example:


// Presuming Ember and Ember-Data are loaded.
var App = Ember.Application.create();

App.User = DS.Model.extend({
name: DS.attr('string')
});

App.Router.map(function(){
this.resource('user', {path: '/user/:user_id'});
});

Ember.TEMPLATES['user'] = Ember.Handlebars.compile("{{user.name}}");

When routing to /user/42, this example will query a server for user 42 and display her name. The :user_id fragment in the resource's path hints to Ember that the User model is being requested. This allows us to avoid explicitly creating a route and model hook.

Like most of Ember, Ember-Data is heavily driven by convention over configuration. In practice, using Ember-Data is fairly configuration-free. However, API and persistence implementations do vary wildly. An understanding of how Ember-Data approaches the challenges of persistence internally is the cornerstone to using the tool successfully. Even if you don't need to modify any of these internals for your first project, it is probably inevitable you will at some point.

After reading through this chapter, come back to the above example and think about what happens behind the scenes.

Asynchronous Actions

Almost any persistence layer used in the browser will be asynchronous. Synchronous APIs like HTML5 localStorage are the exception, and AJAX is usually the rule. How libraries choose to approach asynchronous behavior largely defines their own API and feature set.

In modern JavaScript, there are three common approaches:

  • Callbacks. A method with asynchronous behavior will accept (as an argument) a function to be executed when the action is complete. Sometimes separate success and failure callbacks are passed, and in other examples a single callback will be provided an argument that can be inspected to determine the outcome (as in most Node.js APIs).
  • Events. A method with asynchronous behavior returns an object (or is called on an object) that emits events. Observers are attached to those events to handle success and other statuses.
  • Promises. A method with asynchronous behavior returns a "thennable," an object that reflects fulfillment (success) or unfulfillment (failure). You attach handlers for these states using the then method. Each then returns yet another promise.

Promises have become somewhat universal for managing asynchronicity in Ember, so Ember-Data (and indeed, all the other libraries mentioned) embrace promises. For instance, to find a record in Ember-Data:


store.find('user', 1)
.then(function(user){
// Yes we have a user!
}, function(error){
// 404? 500? Some kind of error.
});

When you use promises in Ember (unlike with other asynchronous handlers), you rarely need to think about the Ember run-loop. When the promise is resolved, it has already been put into a loop (if you plan to author a library, that is another thing entirely).

Ember uses RSVP to implement promises, and all the aforementioned persistence layers do the same. One reason for this is RSVP's configurability- when used via Ember, RSVP schedules certain asynchronous actions into the Ember run loop for a performance improvement.

The Ember Router is also promise-aware, as discussed in chapter 8. In this way, Ember-Data (and other libraries) work smoothly with the loading states provided by the router:


App.UsersIndexRoute = Ember.Router.extend({
model: function(){
return this.get('store').findAll('user');
}
});

This route will wait for model data to return before proceeding to render its template.

Promises are a powerful and very flexible way to manage asynchronous behavior. They can be chained, behave consistently in synchronous and asynchronous situations, and have a powerful (if not slightly confusing) way to handle errors.

Stores

In browser environments, the roundtrip to fetch a persisted item is often very latent. Fetching a single record may take 60 or 70 milliseconds in a best case, with additional delays parsing the data in JavaScript. Often, the times are far worse than this.

This overhead (among other concerns) makes a local, in-memory record cache imperative. In Ember, we call these record caches "stores". Not all of the libraries listed earlier support something like this. The store provided by Ember-Data is a bit more than just a cache, it also provides the primary interface used for working with the library.

Ember-Data uses dependency injection to place the store on all controller and route instances. For example, createRecord can be used in a route to generate a new user for a sign-up form:


var App = Ember.Application.create();

App.User = DS.Model.extend({
username: DS.attr('string'),
password: DS.attr('string')
});

App.Route.map(function(){
this.route('sign_up');
this.route('dashboard');
});

App.SignupRoute = Ember.Route.extend({
model: function(){
return this.get('store').createRecord('user');
},
actions: {
save: function(user){
var route = this;
user.save().then(function(){
route.transitionTo('dashbaord');
})
}
}
});

// Presume some templates.

When debugging, or for certain examples, you can also find the store on Ember's container:

var store = App.__container__.lookup('store:main');

But this is not a stable, public API for using Ember-Data, and you should instead look to the "store" property on controllers and routes.

When requesting a record already in the store, that record will be returned from memory. Records may also be returned and then updated once a request to the persistence endpoint has completed. Most methods on the store return promises, others return live arrays of data.

  • store.find('user', 1) returns a promise that resolves with the matching user.
  • store.find('user', {gender: 'female'}) returns a promise that resolves with an array of users.
  • store.findAll('user') returns a live array of all users. More about this construct is explained in the relationships section.
  • store.createRecord('user', {name: 'Willard'}) returns an unsaved user.
  • store.push('user', {id: '3', name: 'Norman'}) adds a user to the store. It is useful for push data sources like websockets.

"Live" arrays and Ember-Data's server interactions have been intentionally brushed over here. In normal usage of Ember-Data, most code is written against the store or record APIs. Understanding a little of the internals will help you use them effectively and expertly.

API Adapters

Persistence can be performed in many ways. An Ember application may use the HTML5 localStorage or IndexedDB APIs, an HTTP API, a websocket connection, or any of a number of other methods. During test execution, faking persistence may be desired. In order for a persistence library to support all of this, it must have an appropriate layer of abstraction. We call this layer an adapter.

Adapters are only responsible for interacting with the low-level persistence endpoint (such as AJAX). They are not responsible for caching records in memory or maintaining any state at all. They are not (directly) responsible for converting a model into data for an endpoint, only for submitting it to the endpoint

There are three adapters common to Ember persistence libraries:

  • A fixture adapter. Fixture adapters store records in memory, and can load data from objects and arrays in JavaScript. They are often used for prototyping and tests.
  • A REST adapter. Representational state transfer is a popular design strategy for HTTP APIs. In REST, URLs are treated as resources the can be modified by verbs. The actions of create, retrieve, update, and destroy (or CRUD) are mapped onto the HTTP verbs of PUT, CREATE, PATCH, and DELETE. REST APIs are a simple, flexible alternative to the complexity of RPC and SOAP APIs. Implementations of this adapter can range in complexity and extensibility.
  • A JSON-API or ActiveModel::Serializers adapter. JSON-API is an API spec, a guide for an idiomatic Hypermedia REST API. It is a highly opinionated spec, and is partially implemented by the ActiveModel::Serializers Ruby gem. This approach to API construction is supported by many Ember collaborators, and is popular within the community.

In Ember-Data, you can configure a default adapter at several levels. The simplest being to name your custom adapter appropriately:


var App = Ember.Application.create();
// Use an optionally extended copy of the in-memory FixtureAdapter.
App.ApplicationAdapter = DS.FixtureAdapter.extend();

You can also configure an adapter for a specific model, allowing different models to persist in different ways but still have relationships and share an in-memory cache.

Adapters in Ember-Data implement several hooks:

  • generateIdForRecord may provide a uuid for new records.
  • find fetches a single record from the data source by id.
  • findMany fetches several records from the data source by id.
  • findQuery fetches any number of records with query parameters (such as whether a user is female or male).
  • findAll fetches every record.
  • createRecord, updateRecord, and deleteRecord all perform their respective CRUD functions.

All of these hooks (besides generateIdForRecord) are expected to return promises. In this way, a synchronous persistence adapter such as localStorage has the same interface as an asynchronous one like AJAX.

The localStorage adapter is a simple and useful demonstration of a custom adapter.

Serialization/Deserialization

Before data can go to an endpoint, it often requires some conversion. For instance, given a model attribute containing a date, it must first be made a string or integer to be sent as JSON over AJAX. Serializers are responsible for this conversion of attributes and relationships. In turn, they are also responsible for the deserialization of endpoint data into a normalized format for model instantiation.

As mentioned above, adapters are only responsible for submitting and retrieving data from an endpoint. In Ember-Data, they are truthfully also responsible for converting models into data for that endpoint, but if an adapter is implemented as an extension of DS.Adapter (and almost every adapter should be), then Ember-Data provides for custom serializers.

Again, the simplest way to change the default serializer is with appropriate naming:


var App = Ember.Application.create();
// Use an optionally extended copy of the REST serializer.
App.ApplicationSerializer = DS.RESTSerializer.extend();

Ember-Data ships with DS.JSONSerializer, a rather minimal converter between models and JSON, and with DS.RESTSerializer, a serializer mapping objects in a more JSON-API compatible way. For customization, the REST serializer is a likely a better starting point.

The serializer is responsible for transforming a whole object, but also provides an extensible system for transforming attributes. Here is a demonstration:


// Presumes Ember, Ember-Data, and moment.js
var App = Ember.Application.create();

App.User = DS.Model.extend({
name: DS.attr('string'),
signedInAt: DS.attr('moment')
});

App.MomentTransform = DS.Transform.extend({
serialize: function(value) {
if (value) {
return value.format('YYYY-MM-DDTHH:MM:SSZ');
} else {
return value;
}
},
deserialize: function(value) {
if (moment) {
return moment(value);
} else {
return value;
}
}
});

This serializer creates a new Ember-Data attribute type of "moment", which
will serialize and de-serialize to timestamps with timezone information that can be parsed on the server.

Model Relationships

Creating relationships between models poses several challenges, especially in Ember where data-binding means any list that should contain a new model should instantly be updated with it:

  • Given a user having many tickets, if a new ticket is created and the user's ticket collection is displayed, that display should update with the new ticket.
  • Given a user added to an existing ticket, which model becomes "dirty" and must be persisted? If instead a ticket is added to a user, is the behavior different?
  • If a ticket is moved from user A to user B, then user A's ticket set should no longer include that ticket.
  • When requesting a user's tickets, should a collection with local data be returned and updated when authoritative data arrives from a source, or should a promise be returned?
  • And tangential to these issues: When showing the result of a query for all open tickets, if a ticket is closed the query result should be updated to remove the closed ticket.

These are not small challenges. Creating a relationship in Ember-Data is similar to creating attributes on a model:


var App = Ember.Application.create();
App.User = DS.Model.extend({
tickets: DS.hasMany('ticket')
});

App.Ticket = DS.Model.extend({
user: DS.belongsTo('user')
});

Only hasMany and belongsTo relationships exist. They can be polymorphic, and specify special inverse naming. Most notable, they can be asynchronous or synchronous. For example, given the previous example:


// Use the fixture adapter
App.ApplicationAdapter = DS.FixtureAdapter;
// Normally the store is available on controllers and routes,
// here we must fetch it.
var store = App.__container__.lookup('store:main');

var user = store.createRecord('user');
var userTickets = user.get('tickets');

userTickets.get('length'); // => 0

var ticket = store.createRecord('ticket', {user: user});

userTickets.get('length'); // => 1

Fetching a relationship with get returns a live array, a set of objects that updates itself based on whatever is in the store (persisted or unpersisted). Alternatively, the relationships can be defined as asynchronous:


var App = Ember.Application.create();
App.ApplicationAdapter = DS.FixtureAdapter;

var store = App.__container__.lookup('store:main');

App.User = DS.Model.extend({
tickets: DS.hasMany('ticket', {async: true})
});

App.Ticket = DS.Model.extend({
user: DS.belongsTo('user')
});

var user = store.createRecord('user'),
userTickets;

// The tickets relationship is async, so a promise
// is returned instead of a live array.
user.get('tickets').then(function(tickets){
userTickets = tickets;
});

userTickets.get('length'); // => 0

var ticket = store.createRecord('ticket', {user: user});

// The promise returned a normal array of objects, not
// a live array. The length will not change despite the new
// object added to the store.
userTickets.get('length'); // => 0

Ember-Data's relationship code has been through several painful refactors, but at this writing has seemingly stabilized in to quite a mature and capable solution. Where there are tradeoffs to be made, it at least offers you clear choices.

Model Resolution

Model resolution, how a model used in a relationship or passed to the store, is handled differently in each of the libraries listed above. Model resolution is basically a dependency problem, and therefore (in JavaScript) a namespace and load-order challenge. Ember-Data takes advantage of an Ember feature designed to manage just this set of issues: Containers.

In the prior section on relationships, we related two models with a string. To bind the tickets of a user to the ticket model, the string "tickets" was passed to DS.hasMany.

This exposes a little of Ember's container usage. Instead of hardcoding the fetching of models, the store will look to the application's container to lookup models.

// …snip
App.Ticket.extend({
user: DS.belongsTo('user')
});

The "user" string is used (internally) to create a call to the container:

userClass = container.lookupFactory('models:user');

And thus models flow through the same loading and dependency injection framework used by controllers, routes, views, and pretty much everything else in Ember. This is an elegant and powerful solution.

Taking Ember-Data Into Production

Take a look back at the first "at a glance" example at the start of this chapter. Impressive that so many abstractions are made so simple!

Ember-Data's terse syntax for basic usage and clean abstractions make it a quick and flexible platform for app development. For testing applications, the fixture adapter's simple API and ability to emulate asynchronous backends makes it a popular choice. An application can even be prototyped using the fixture adapter, then moved over all at once or model-by-model to a REST adapter for live data. Customizable serializers and adapters can make legacy APIs act like modern ones in application code.

In addition to Ember-Data's own built-in adapters, there are a plethora of other adapters provided by the community and by API authors.

Persistence in the browser is a complicated challenge. In the next four chapters we will enter the practical phase of this book, and we will see how Ember-Data manages that complexity.

There has been error in communication with Booktype server. Not sure right now where is the problem.

You should refresh this page.