Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Going Realtime with Ember

Going Realtime with Ember

It used to be that only the most impressive websites would update data live as you sat on the page. Now, as the lines between native apps and websites blur, this is becoming expected behavior. What was once cutting-edge tech is now standard-issue for a good user experience.

See how HashiCorp made the UI for the cluster scheduler software Nomad realtime with Ember Concurrency, Ember Data, and the rendering layer we know and love.

Presented at EmberFest 2018

Link to the mentioned pull request: https://github.com/hashicorp/nomad/pull/3936

Slide Deck template © HashiCorp 2018

Michael Lange

October 11, 2018
Tweet

More Decks by Michael Lange

Other Decks in Technology

Transcript

  1. ▪ Lots of stuff going on all the time ▪

    Most action is coming from the cluster and users want to stay informed ▪ We can’t get away with waiting for user action Nomad: Takeaway 7
  2. ▪ Twitter tells me about new tweets ▪ My email

    client pushes notifications ▪ Lyft will show me where my ride is (kinda) ▪ Spotify will tell me what my friends are listening to ▪ Why doesn’t YOUR web app do that? Can’t it just do that automatically? 9
  3. ! Warning! This talk doesn’t end with ember install. I’m

    just gonna talk about stuff. Hopefully that’s still useful?
  4. s 14 Step 1 Understand the API Step 2 Break

    Down the Problem Step 3 Make it Work for One Page Step 4 Find the Right Abstractions Step 5 Celebrate
  5. Copyright © 2018 HashiCorp ▪ Long-polling ▪ Using the index

    query param ▪ Each URL maintains a monotonic index ▪ X-Nomad-Index header in response is the new current index 15 Nomad API
  6. Copyright © 2018 HashiCorp ▪ /job/job-1 (current index 10) ▪

    Immediately resolve ▪ /job/job-1?index=1 (current index 10) ▪ Immediately resolve ▪ /job/job-1?index=10 (current index 10) ▪ Block until the index increments 16 Nomad API
  7. ▪ APIs can’t always be changed on a whim for

    the benefit of the UI ▪ This API pre-dates the Web UI ▪ This API works just fine Answer: This API already exists 22
  8. ▪ Low-tech solution ▪ It’s entirely stateless ▪ It’s naturally

    fault-tolerant Answer: This API is actually good 23
  9. s 24 Step 1 Understand the API Step 2 Break

    Down the Problem Step 3 Make it Work for One Page Step 4 Find the Right Abstractions Step 5 Celebrate
  10. 1. Implement long-polling 2. Update pages to use long-polling 3.

    Re-render all the things when long-polls resolve with new data Breaking down the problem 25
  11. 26 Long polling in Bash # Starting index idx="1" while

    true do # Append index as a query param url="http://localhost:4646/v1/job/example?index=$idx" echo -e "\nCurling $url..." curl -i -s $url | grep -v "^" > polltmp while read -r line do # Use the X-Nomad-Index header to set the new index key="$(echo $line | cut -d ':' -f 1)" val="$(echo $line | cut -d ':' -f 2 | tr -d '[:space:]')" if [ "$key" = "X-Nomad-Index" ] then idx="$val" fi done < polltmp # Do stuff with the payload tail -1 polltmp | jq ". | {Name, Status, ModifyIndex}" done simplepoll.sh
  12. 27 Long polling in Bash # Starting index idx="1" while

    true do # Append index as a query param url="http://localhost:4646/v1/job/example?index=$idx" echo -e "\nCurling $url..." curl -i -s $url | grep -v "^" > polltmp while read -r line do # Use the X-Nomad-Index header to set the new index key="$(echo $line | cut -d ':' -f 1)" val="$(echo $line | cut -d ':' -f 2 | tr -d '[:space:]')" if [ "$key" = "X-Nomad-Index" ] then idx="$val" fi done < polltmp # Do stuff with the payload tail -1 polltmp | jq ". | {Name, Status, ModifyIndex}" done simplepoll.sh
  13. 28 Long polling in Bash # Starting index idx="1" while

    true do # Append index as a query param url="http://localhost:4646/v1/job/example?index=$idx" echo -e "\nCurling $url..." curl -i -s $url | grep -v "^" > polltmp while read -r line do # Use the X-Nomad-Index header to set the new index key="$(echo $line | cut -d ':' -f 1)" val="$(echo $line | cut -d ':' -f 2 | tr -d '[:space:]')" if [ "$key" = "X-Nomad-Index" ] then idx="$val" fi done < polltmp # Do stuff with the payload tail -1 polltmp | jq ". | {Name, Status, ModifyIndex}" done simplepoll.sh
  14. 29 Long polling in Bash # Starting index idx="1" while

    true do # Append index as a query param url="http://localhost:4646/v1/job/example?index=$idx" echo -e "\nCurling $url..." curl -i -s $url | grep -v "^" > polltmp while read -r line do # Use the X-Nomad-Index header to set the new index key="$(echo $line | cut -d ':' -f 1)" val="$(echo $line | cut -d ':' -f 2 | tr -d '[:space:]')" if [ "$key" = "X-Nomad-Index" ] then idx="$val" fi done < polltmp # Do stuff with the payload tail -1 polltmp | jq ". | {Name, Status, ModifyIndex}" done simplepoll.sh
  15. 30 Long polling in Bash # Starting index idx="1" while

    true do # Append index as a query param url="http://localhost:4646/v1/job/example?index=$idx" echo -e "\nCurling $url..." curl -i -s $url | grep -v "^" > polltmp while read -r line do # Use the X-Nomad-Index header to set the new index key="$(echo $line | cut -d ':' -f 1)" val="$(echo $line | cut -d ':' -f 2 | tr -d '[:space:]')" if [ "$key" = "X-Nomad-Index" ] then idx="$val" fi done < polltmp # Do stuff with the payload tail -1 polltmp | jq ". | {Name, Status, ModifyIndex}" done simplepoll.sh
  16. ▪ Inaccurate project estimates ▪ Fear of certain files or

    subsystems ▪ Habitual refactoring ▪ Unhappy developers ▪ Unconfident developers Symptoms include… 36
  17. Smiley in Open Sans 2018, Michael Lange Minerva in Her

    Study 1635, Rembrandt van Rijn https://www.theleidencollection.com/artwork/minerva-in-her-study/
  18. Smiley in Open Sans 2018, Michael Lange Minerva in Her

    Study 1635, Rembrandt van Rijn https://www.theleidencollection.com/artwork/minerva-in-her-study/ 100% Abstract 0% Abstract
  19. s 46 Step 1 Understand the API Step 2 Break

    Down the Problem Step 3 Make it Work for One Page Step 4 Find the Right Abstractions Step 5 Celebrate
  20. ▪ Adapters ▪ Index query param ▪ Conditionally use the

    query param (allow non-blocking requests) ▪ For every adapter that supports blocking queries ▪ Serializers ▪ Nothing ▪ Models ▪ Nothing Changes to the data layer 47
  21. 48 Changes to the data layer this.get('store').findRecord('job', id, { reload:

    true, adapterOptions: { watch: true }, }); route.js
  22. 49 Changes to the data layer findRecord(store, type, id, snapshot)

    { if (get(snapshot || {}, 'adapterOptions.watch')) { // do some stuff for blocking queries } return this._super(...arguments); }, watchable.js
  23. ▪ How do we track the X-Nomad-Index value? ▪ WatchList

    service Changes to the data layer 50
  24. 53 Changes to the data layer watchList: service(), // ...

    findRecord(store, type, id, snapshot) { const fullUrl = this.buildURL(type.modelName, id, snapshot, 'findRecord'); let [url, params] = fullUrl.split('?'); params = assign(queryString.parse(params) || {}, this.buildQuery()); if (get(snapshot || {}, 'adapterOptions.watch')) { params.index = this.get('watchList').getIndexFor(url); } return this.ajax(url, 'GET', { data: params, }); }, watchable.js
  25. 54 Changes to the data layer handleResponse(status, headers, payload, requestData)

    { // Some browsers lowercase all headers. Others keep them // case sensitive. const newIndex = headers['x-nomad-index'] || headers['X-Nomad-Index']; if (newIndex) { this.get('watchList').setIndexFor(requestData.url, newIndex); } return this._super(...arguments); }, watchable.js
  26. 55 Just an object with write protections. Changes to the

    data layer import { readOnly } from '@ember/object/computed'; import { copy } from '@ember/object/internals'; import Service from '@ember/service'; let list = {}; export default Service.extend({ list: readOnly(function() { return copy(list, true); }), init() { list = {}; }, getIndexFor(url) { return list[url] || 1; }, setIndexFor(url, value) { list[url] = +value; }, }); services/watch-list.js
  27. ▪ Model hook stays the same ▪ Immediately get the

    current state of the model in question ▪ Need some sort of polling mechanism to continuously fetch the model using our new adapterOption Changes to the route 56
  28. 58 Polling loop import Route from '@ember/routing/route'; import { task,

    timeout } from 'ember-concurrency'; export default Route.extend({ setupController(controller, model) { this.get('watch').perform(model); return this._super(...arguments); }, watch: task(function*(model) { while (!Ember.testing) { try { yield this.get('store').findRecord('job', model.get('id'), { reload: true, adapterOptions: { watch: true }, }); yield timeout(2000); } catch (e) { yield e; } } }), }); routes/jobs/job/index.js
  29. 59 Polling loop import Route from '@ember/routing/route'; import { task,

    timeout } from 'ember-concurrency'; export default Route.extend({ setupController(controller, model) { this.get('watch').perform(model); return this._super(...arguments); }, watch: task(function*(model) { while (!Ember.testing) { try { yield this.get('store').findRecord('job', model.get('id'), { reload: true, adapterOptions: { watch: true }, }); yield timeout(2000); } catch (e) { yield e; } } }), }); routes/jobs/job/index.js
  30. 63 Watching lists watchList: service(), // ... findAll(store, type, sinceToken,

    snapshotRecordArray) { const params = this.buildQuery(); const url = this.urlForFindAll(type.modelName); if (get(snapshotRecordArray || {}, 'adapterOptions.watch')) { params.index = this.get('watchList').getIndexFor(url); } return this.ajax(url, 'GET', { data: params, }); }, watchable.js
  31. ▪ No existing store method for fetching a relationship ▪

    It’s done by reading properties on models ▪ Solution: a new reloadRelationship method Watching relationships 64
  32. 65 Models have APIs for looking up details for a

    relationship Watching relationships reloadRelationship(model, relationshipName, watch = false) { const relationship = model.relationshipFor(relationshipName); if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') { throw new Error( `${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}` ); } else { const url = model[relationship.kind](relationship.key).link(); let params = {}; if (watch) { params.index = this.get('watchList').getIndexFor(url); } // Avoid duplicating existing query params by passing them to ajax // in the URL and in options.data if (url.includes('?')) { const paramsInUrl = queryString.parse(url.split('?')[1]); Object.keys(paramsInUrl).forEach(key => { delete params[key]; }); } return this.ajax(url, 'GET', { data: params, }).then( json => { const store = this.get('store'); const normalizeMethod = relationship.kind === 'belongsTo' ? 'normalizeFindBelongsToResponse' : 'normalizeFindHasManyResponse'; const serializer = store.serializerFor(relationship.type); const modelClass = store.modelFor(relationship.type); const normalizedData = serializer[normalizeMethod](store, modelClass, json); store.push(normalizedData); }, error => { if (error instanceof AbortError) { return relationship.kind === 'belongsTo' ? {} : []; } throw error; } ); } }, watchable.js
  33. ▪ findAll case ▪ Remove records from the store that

    aren’t also in the new payload. ▪ watchRelationship case ▪ Remove records from the store of the relationship type that aren’t also in this payload. Removing records 66
  34. 68 By default, adds and removes relationship links related to

    this model Updating hasMany findHasMany(store, snapshot, link, relationship) { return this._super(...arguments); } adapters/application.js
  35. 70 By using the relationship traversal APIs we can remove

    the record on the other side of the relationship Removing related records findHasMany(store, snapshot, link, relationship) { return this._super(...arguments).then(payload => { const relationshipType = relationship.type; const inverse = snapshot.record.inverseFor(relationship.key); if (inverse) { store .peekAll(relationshipType) .filter(record => record.get(`${inverse.name.id}`) === snapshot.id) .forEach(record => { record.unloadRecord(); }); } return payload; }); } adapters/application.js
  36. 72 By using store.push and JSONAPI, we can also clear

    the relationships on the related model we’re unloading Clearing removed record relationships export default function removeRecord(store, record) { const relationshipMeta = []; record.eachRelationship((key, { kind }) => { relationshipMeta.push({ key, kind }); }); store.push({ data: { id: record.get('id'), type: record.constructor.modelName, relationships: relationshipMeta.reduce((hash, rel) => { hash[rel.key] = { data: rel.kind === 'hasMany' ? [] : null }; return hash; }, {}), }, }); record.unloadRecord(); } // Sample JSONAPI payload provided to store.push // { // id: “alloc-1”, // type: “allocation”, // relationships: { // job: null, // node: null, // evaluations: [], // } // } utils/remove-record.js
  37. ▪ New service for tracking indices ▪ findRecord override ▪

    findAll override ▪ New reloadRelationship ▪ Handle removing data from the store Now it works 75
  38. s 76 Step 1 Understand the API Step 2 Break

    Down the Problem Step 3 Make it Work for One Page Step 4 Find the Right Abstractions Step 5 Celebrate
  39. Developer Concerns Abstracted Details Requesting models Index tracking Deciding what

    data to poll Removing stale data Deciding how to poll Deciding when to poll
  40. Developer Concerns Abstracted Details Requesting models Index tracking Deciding what

    data to poll Removing stale data Deciding how to poll Deciding when to poll
  41. Developer Concerns Abstracted Details Requesting models Index tracking Deciding what

    data to poll Deciding how to poll Deciding when to poll Removing stale data
  42. Developer Concerns Abstracted Details Requesting models Index tracking Removing stale

    data Deciding what data to poll Deciding how to poll Deciding when to poll
  43. Developer Concerns Abstracted Details Requesting models Index tracking Deciding what

    data to poll Removing stale data Deciding how to poll Deciding when to poll
  44. ▪ Already using Ember Concurrency tasks ▪ Computed property macros

    ▪ What are truly parameters and not the mechanics of polling? Abstracting How to Poll 82
  45. 83 What are truly parameters and not the mechanics of

    polling? Task Macro utils/properties/watch.js import Ember from 'ember'; import { get } from '@ember/object'; import RSVP from 'rsvp'; import { task, timeout } from 'ember-concurrency'; export function watchRecord(modelName) { return task(function*(id, throttle = 2000) { if (typeof id === 'object') { id = get(id, 'id'); } while (!Ember.testing) { try { yield this.get('store').findRecord(modelName, id, { reload: true, adapterOptions: { watch: true }, }); yield timeout(throttle); } catch (e) { yield e; } } }).drop(); }
  46. 85 Remove the responsibility of choosing when to start polls

    from routes. Mixin import Mixin from '@ember/object/mixin'; import { computed } from '@ember/object'; import { assert } from '@ember/debug'; export default Mixin.create({ watchers: computed(() => []), cancelAllWatchers() { this.get('watchers').forEach(watcher => { assert( 'Watchers must be Ember Concurrency Tasks.', !!watcher.cancelAll ); watcher.cancelAll(); }); }, startWatchers() { assert('startWatchers needs to be overridden in the Route', false); }, setupController() { this.startWatchers(...arguments); return this._super(...arguments); }, actions: { willTransition() { this.cancelAllWatchers(); }, }, }); mixin/with-watchers.js
  47. 86 So clean! import Route from '@ember/routing/route'; import { collect

    } from '@ember/object/computed'; import { watchAll } from 'nomad-ui/utils/properties/watch'; import WithWatchers from 'nomad-ui/mixins/with-watchers'; export default Route.extend(WithWatchers, { startWatchers(controller) { controller.set('modelWatch', this.get('watch').perform()); }, watch: watchAll('job'), watchers: collect('watch'), }); routes/jobs/index.js
  48. 87 Some nuance is still necessary. Route-specific behavior import Route

    from '@ember/routing/route'; import { collect } from '@ember/object/computed'; import { watchRecord, watchRelationship, watchAll } from 'nomad-ui/utils/properties/watch'; import WithWatchers from 'nomad-ui/mixins/with-watchers'; export default Route.extend(WithWatchers, { startWatchers(controller, model) { if (!model) { return; } controller.set('watchers', { model: this.get('watch').perform(model), summary: this.get('watchSummary').perform(model.get('summary')), allocations: this.get('watchAllocations').perform(model), evaluations: this.get('watchEvaluations').perform(model), latestDeployment: model.get('supportsDeployments') && this.get('watchLatestDeployment').perform(model), list: model.get('hasChildren') && this.get('watchAll').perform(), }); }, watch: watchRecord('job'), watchAll: watchAll('job'), watchSummary: watchRecord('job-summary'), watchAllocations: watchRelationship('allocations'), watchEvaluations: watchRelationship('evaluations'), watchLatestDeployment: watchRelationship('latestDeployment'), watchers: collect( 'watch', 'watchAll', 'watchSummary', 'watchAllocations', 'watchEvaluations', 'watchLatestDeployment' ), }); routes/jobs/job/index.js
  49. s 90 Step 1 Understand the API Step 2 Break

    Down the Problem Step 3 Make it Work for One Page Step 4 Find the Right Abstractions Step 5 Step 6 Celebrate Step 5 Fight the Bugs
  50. ▪ Has nothing to do with Ember ▪ Has nothing

    to do with the code we just wrote ▪ Has everything to do with browsers having a max number of connections per domain Requests are stuck pending, what gives? 91
  51. s 94 Most people are making the best decisions they

    can in the situations they are in.
  52. HTTP/1.1 RFC 2616 95 A single-user client SHOULD NOT maintain

    more than 2 connections with any server or proxy. … These guidelines are intended to improve HTTP response times and avoid congestion. — https://tools.ietf.org/html/rfc2616#page-46
  53. Copyright © 2018 HashiCorp ▪ Browsers will only use HTTP/2

    over a secure connection ▪ Nomad supports TLS, but it is optional 97 Why we can’t use HTTP/2
  54. ▪ Use multiple sub-domains to bypass the max connections per

    domain limit. ▪ Doesn’t even matter if all you’re doing is creating a reverse proxy to the same exact server Solution 2: Domain Sharding 98
  55. Copyright © 2018 HashiCorp ▪ Not SaaS! ▪ Customers install

    Nomad on their own machines ▪ We take operational simplicity very seriously 99 Why we can’t use Domain Sharding
  56. ▪ HTTP requests can be canceled ▪ XMLHttpRequest objects have

    an abort method for triggering cancellation ▪ Ember Data uses XMLHttpRequests ▪ However, Ember Data does not provide abort hooks Solution 3: Request Cancellation 101
  57. ▪ Capture XHRs in some sort of cache to abort

    later ▪ Remove XHRs from the cache when they resolve ▪ Write cancel methods that mirror the watchable method signatures ▪ Call the cancel methods in our polling code Doing Request Cancellation with Ember Data Anyway 102
  58. 103 A registry mapping keys to queues of XHRs. Capture

    all the XHRs xhrs: computed(function() { return { list: {}, track(key, xhr) { if (this.list[key]) { this.list[key].push(xhr); } else { this.list[key] = [xhr]; } }, cancel(key) { while (this.list[key] && this.list[key].length) { this.remove(key, this.list[key][0]); } }, remove(key, xhr) { if (this.list[key]) { xhr.abort(); this.list[key].removeObject(xhr); } }, }; }), watchable.js
  59. 104 ajaxOptions provides an opportunity to capture an XHR to

    put in our registry. Capture all the XHRs ajaxOptions() { const ajaxOptions = this._super(...arguments); const key = this.xhrKey(...arguments); const previousBeforeSend = ajaxOptions.beforeSend; ajaxOptions.beforeSend = function(jqXHR) { if (previousBeforeSend) { previousBeforeSend(...arguments); } this.get('xhrs').track(key, jqXHR); jqXHR.always(() => { this.get('xhrs').remove(key, jqXHR); }); }; return ajaxOptions; }, watchable.js
  60. 105 In order to control cancellation from outside adapters. Cancel

    Methods cancelFindRecord(modelName, id) { if (!modelName || id == null) { return; } const url = this.urlForFindRecord(id, modelName); this.get('xhrs').cancel(`GET ${url}`); }, cancelFindAll(modelName) { if (!modelName) { return; } let url = this.urlForFindAll(modelName); const params = queryString.stringify(this.buildQuery()); if (params) { url = `${url}?${params}`; } this.get('xhrs').cancel(`GET ${url}`); }, cancelReloadRelationship(model, relationshipName) { if (!model || !relationshipName) { return; } const relationship = model.relationshipFor(relationshipName); if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') { throw new Error( `${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}` ); } else { const url = model[relationship.kind](relationship.key).link(); this.get('xhrs').cancel(`GET ${url}`); } }, watchable.js
  61. 106 Stopping polling should result in canceling requests. Call Cancel

    Methods export function watchRecord(modelName) { return task(function*(id, throttle = 2000) { if (typeof id === 'object') { id = get(id, 'id'); } while (!Ember.testing) { try { yield this.get('store').findRecord(modelName, id, { reload: true, adapterOptions: { watch: true }, }); yield timeout(throttle); } catch (e) { yield e; } finally { this.get('store') .adapterFor(modelName) .cancelFindRecord(modelName, id); } } }).drop(); } watchable.js
  62. ▪ An Aborted XHR results in an AbortError being thrown

    ▪ We don’t want an error thrown, we expect an abort! ▪ Handle the AbortError case in the adapter find methods Unforeseen Consequences: Error Handling 107
  63. 108 A downside of promise- based control-flow. Handle expected errors

    import { AbortError } from 'ember-data/adapters/errors'; // ... findRecord(store, type, id, snapshot) { const fullUrl = this.buildURL(type.modelName, id, snapshot, 'findRecord') let [url, params] = fullUrl.split('?'); params = assign(queryString.parse(params) || {}, this.buildQuery()); if (get(snapshot || {}, 'adapterOptions.watch')) { params.index = this.get('watchList').getIndexFor(url); } return this.ajax(url, 'GET', { data: params, }).catch(error => { if (error instanceof AbortError) { return; } throw error; }); }, watchable.js
  64. s 109 Step 1 Understand the API Step 2 Break

    Down the Problem Step 3 Make it Work for One Page Step 4 Find the Right Abstractions Step 5 Step 6 Celebrate Step 5 Fight the Bugs
  65. Look back on what we built 110 New service to

    manage indices New methods for reloading data and canceling requests New methods for removing data from the store Reusable patterns for adding realtime behaviors to a page Polling code to continuously long-poll and stop on demand.
  66. The code we didn’t write 111 An architecture for maintaining

    an interface with a backend A way to make any data consistent from within the app State mgmt for records A well-rounded modern approach to asynchronous control-flow An object and inheritance model that makes it easy to build abstractions
  67. Distribution of code that gets run 113 Ember Ember Data

    Addons Nomad UI This is totally unscientific, just trying to drive a point home
  68. ▪ Products are all trying to be unique and differentiated

    ▪ By definition frameworks only solve common problems ▪ We can use addons and build on top of Ember to create great new things ▪ We can think like framework authors to build software that might withstand the test of time and keep our coworkers happy. The Takeaways 114
  69. HashiCorp ❤ Open Source & Ember.js 117 Consul + Consul

    UI https://github.com/hashicorp/consul Vault + Vault UI https://github.com/hashicorp/vault Nomad + Nomad UI https://github.com/hashicorp/nomad Terraform Enterprise Not open source, but still Ember!