Our SPA

From Organic Design wiki
Revision as of 16:20, 8 September 2017 by Nad (talk | contribs) (Database schemas and querying)

We're currently working on several apps which are in the form of Single Page Applications. The general purpose which is in common with all the projects is that they're all asset (products, services, currencies etc) market-places which require user profile, account and authentication, Google map integration (since the listed assets usually have a location aspect), asset filtering and searching, auction aspect, feedback and asset-specific discussion. All this requires real-time bidirectional communications and back-end database integration. This article describes the details of this common SPA market-place structure.

Technology stack

Our system uses NodeJS on the server side with FeathersJS for authentication (using ExpressJS) and real-time bidirectional communications (using SocketIO). We use MongoDB for our no-SQL database layer and Mongoose to integrate it with NodeJS. On the client side we use the VueJS framework for templating and component model with the Vue router and Vuex storage layer.

To manage all our separate source files, assets and dependencies we use NPM (Node package manager) and WebpackJS (see this for a good noob intro to Webpack) which integrates tightly with NodeJS and it's build process.

Application initialisation

When the application starts a number of requests to the server side need to be made for things such as user and localisation details. Before this information has arrived and been used to initialise the environment, the site should show only a loading screen.

This has been done by initialising the Vue router with a default "catch-all" route to the "Loading" router component. The initialisation sequence is then run (using $.when so they can all load asynchronously in parallel), and then the proper routes are switched in on completion and installation of the sequence. After the routes are switched over, the current route needs to be re-rendered since it will now use different components which is achieved by using router.replace(router.currentPath). This is all done in main.js directly after the instantiation of the main App Vue component since the initialisation sequence depends on the Vuex App.store object.

  • Note1: This requires at least version 2.7 of the Vue router because prior to that adding new routes would not work if there was already a catch-all route installed.
  • Note2: You can't actually push a new route that resolves to the current route, so first we have to call router.replace('/dummy-route') first.

Application state

We're using the Vuex store module to manage the application state, in preference to having a globally accessible state object that any component can access and update. Note that a globally accessible object is still used for cache-like data which is useful to have accessible by any component, but is only updated by a single component, and is also client-side only never leaving the current application instance. There are three aspects to the Vuex storage model, Getters, Mutations and Actions.

Getters

Although the state is available directly as an object from any component (via this.$store.state), the preferred method is to access them via Getter methods instead so that the data can be combined, formatted or otherwise transformed before being returned.

Mutations & Actions

Mutations and actions are more confusing as at first glance it seems that only mutations are necessary and actions are just a pointless extra layer. However the logic behind this decision is that mutations directly and synchronously (instantly) change state and have no other side-effects, whereas actions can be dispatched asynchronously, but cannot manipulate state directly, they can only commit mutations. See some comments by Evan on these design decisions here and this example of migrating a simple Vue app to Vuex.

In summary, mutations can be called directly by component, e.g. this.$store.commit(SOME_MUTATATION, options), but should only be done if it's synchronous code calling it. Usually a mutation is done in response to some asynchronous result which would be in the form of a dispatched action. The actions contain asynchronous code and may commit one or more mutations at some point in the future, they can also have other side-effects such as triggering events or updating interface items.

Another good way of looking at mutations compared to actions, is that mutations don't contain any business logic, they're solely concerned with changing the state. While actions never touch the state and are all about business logic and which mutation(s) the logic leads to.

Database schemas and querying

MongoDB is a document modelled database not a relational model, see Thinking documents for an intro to the differences.

The most basic type of query is approached the same way, where we can ask for a set of documents (rows) from a collection (table) that fit a certain simple criteria of various properties (columns) being equal to a certain value. For simple queries involving operators other than the default equality operator, query selectors are used. For controlling returned fields, we need to use the feathers-mongoose $select syntax rather than using the Mongo/Mongoose projection parameter (also note that our schema data types use the Mongoose syntax).

For more complex querying requirements, Mongo (and most NoSQL databases) offer the map/reduce pattern, but Mongo also has a mechanism called the aggregation pipeline which allows queries to be "piped" together UNIX style, there are many operators available including map and reduce (good introductory tutorial here). This allows the construction of arbitrarily complex queries involving any number of collections which is Mongo's answer to the missing SQL join functionality.

Unfortunately, the aggregation pipeline is not available to feathers-mongoose services because it's designed to be a generic database abstraction layer that allows different databases to be easily switched in and out with minimal changes to the code. Feathers is extremely flexible though and its default functionality can be overridden to give access to the aggregation pipleline.

We've done this in our SPA by adding a before.find hook which checks if the query has a top-level key called _aggregate, and if so then the data is sent directly to the aggregation method of the Mongoose Model object (note that this occurs on the server side). By setting hook.result to the returned Promise the original find query is cancelled. Here's the content of the hook function we added to before.find.

function(hook) {
	if('_aggregate' in hook.params.query) {
		hook.result = hook.service.Model.aggregate(hook.params.query._aggregate);
	}
}

An aggregation query can then be done using the find method from the Feathers service, for example:

foo.find({
	query: {
		_aggregate: [
			$match: {
				baz: "buz"
			},
			$lookup: {
				from: 'bar',
				localField: '_id',
				foreignField: 'foo_id',
				as: 'bars'
			},
			$project: {
				baz: 1
				bars: 1
			}
		]
	}
});

Note: It's a bit inefficient to have large complex aggregation queries sent from client to server though (the actual object representing the whole query is client side and sent to the server), so this same process can be used to create other custom top-level keys that direct to specific aggregation queries on the server.

See also