async / await deep-dive

Une présentation de Christophe Porteneuve at Node.js Paris

whoami


const christophe = {
  age: 39.49007529089665,
  family: { wife: 'Élodie', son: 'Maxence' },
  city: 'Paris, FR',
  company: 'Delicious Insights',
  trainings: ['JS Total', 'Node.js', 'Git Total'],
  upcoming: ['ES2015+', 'GitHub', 'Performance Web'],
  webSince: 1995,
  claimsToFame: [
    'Prototype.js',
    'Ruby On Rails',
    'Prototype and Script.aculo.us',
    'Paris Web',
    'NodeSchool Paris'
  ]
}
          

Rappels

vite fait

callbacks : la primitive de base, incontournable


router.get('/ohai', (req, res) => {
  res.json({ result: 'success' })
})

$(document).on('click', 'a.js-toggle', (event) => {
  $(event.currentTarget).toggle()
})
            

Callback Hell

(Pyramid Of Doom)


router.get('/posts/:id', (req, res) => {
  db.posts.find(req.params.id, (err, post) => {
    if (err) return handleError(err)

    post.author.find((err, author) => {
      if (err) return handleError(err)

      post.comments.find({ limit: 10 }, (err, comments) => {
        if (err) return handleError(err)

        res.render('posts/show', { post, author, comments })
      })
    })
  })
})
            

async.js


const { waterfall } = require('async')

router.get('/posts/:id', (req, res) => {
  let post, author

  waterfall([
    (cb) => db.posts.find(req.params.id, cb),
    (p, cb) => (post = p).author.find(cb),
    (a, cb) => (author = a).comments.find({ limit: 10 }, cb),
    (comments) =>
      res.render('posts/show', { post, author, comments })
  ], handleError)
})

            

Soucis inhérents

Piles d’appels asynchrones…


ReferenceError: lookMaNoMethod is not defined
    at Timeout.setTimeout (repl:1:18)
    at ontimeout (timers.js:380:14)
    at tryOnTimeout (timers.js:244:5)
    at Timer.listOnTimeout (timers.js:214:5)
            

Soucis inhérents

Le style error-first


router.get('/posts/:id', (req, res) => {
  db.posts.find(req.params.id, (err, post) => {
    post.author.find((err, author) => {
      post.comments.find({ limit: 10 }, (err, comments) => {
        res.render('posts/show', { post, author, comments })
      })
    })
  })
})
            

Soucis inhérents

Après l’heure, c’est plus l’heure


mongoose.on('open', () => {
  // If Mongoose was already connected, tough luck.
})
            

Soucis inhérents

Le piège du return manquant


function badActor (auth, cb) {
  verifyCredentials(auth, (err, info) => {
    if (err) {
      cb(err) // Oops, no return!…
    }
    persistAccess(info)
    // …so let's mess it up!
    cb(null, { token: info.token })
  })
}
            

Soucis inhérents

Zalgo !


function zalgoFetcher (key, cb) {
  if (cache.hasOwnProperty(key)) {
    // Synchronous call
    return cb(null, cache[key])
  }

  fetchFromAPI(key, (err, data) {
    if (err) {
      return cb(err);
    }
    cache[key] = data
    // Asynchronous call
    cb(null, data)
  })
}
            

Promesses

Pas juste une syntaxe alternative, une « mise à plat »

Promesses = garanties contre des anti-patterns

Qui plus est, facilement composable

bit.ly/dotjs-async

Devient vite lisible à l’usage mais…

…sera toujours moins lisible que du bon vieux code bloquant : conditions, boucles, try…catch

async/await

petite intro

Soyons clairs.

Ce n’est pas : callbacks ➡️ promesses ➡️ async/await

C’est intimement lié aux promesses

Ça ne les remplace pas

ES2017

Fait partie (depuis juillet 2016) du dernier standard officiel

Syntaxe déjà prise en charge nativement

v8 5.5, donc Cr55, Op42, Saf 10.1, Node 7.6

Par ailleurs, Fx52 (mars 2017), Edge build 14986 (14/12/2016)

Pour le reste, Babel (latest / env)

Pierre angulaire

de nombreux outils

micro

RunKit

AVA

Clairement le futur

(mais maintenant)

La syntaxe

show me some code!

Marquage async

Fonctions classiques :


async function foo (itemId) {
  const details = await fetchItem(itemId)
  doInternalWork(details)
  return details
}
            

Ou fonctions fléchées :


connection.on('open', async () => {
  await syncNoSQL(connection)
  infoLog('DB ready, NoSQL sync’ed.')
})
            

Promesse implicite

Une fonction async renvoie implicitement une promesse : on peut la consommer tant en « gelé » avec un await qu’en « futur » avec un bon vieux .then.


const result = await foo()
// …but could also be…
foo().then((result) => …)
            

Du coup ça marche partout où les promesses marchent (harnais de test, libs d’assertions : Mocha, Jest, Chai-as-Promised…)


it('should allow status pings', async () => {
  const { result } = await apiCall('/ohai')
  expect(result).to.equal('success')
})
            

await dans le corps immédiat

Attention à ne pas se faire avoir par les callbacks internes discrets


async function foo () {
  getRootId((id) => {
    // Current function is the callback: no `async`,
    // so no `await`!
    const item = await fetchItem(id)
    processItem(item)
  })
}
            

On await une promesse

On await toujours une promesse

(ce qui inclue, du coup, tout appel de fonction async)

Si on await sur quoi que ce soit d’autre, ça enrobe simplement via un classique Promise.resolve, tout comme au sein d’une chaîne de promesses :


async function foo () {
  // will turn into a `Promise.resolve(42)`
  return await 42
}
            
(le code ci-dessus est turbo-crétin, mais bon, voilà)

Exemples

(fais-moi rêver)

Handler Express basique


async function showUser (req, res) {
  try {
    const user = await User.find(req.params.id)
    res.render('users/show', { user })
  } catch (err) {
    handleError(err)
  }
}
            

Handler Express waterfall


async function showPost (req, res) {
  try {
    const post = await db.posts.find(req.params.id)
    const author = await post.author.find()
    const comments = await post.comments.find({ limit: 10 })
    res.render('posts/show', { post, author, comments })
  } catch (err) {
    handleError(err)
  }
}
            

Parallélisation

de requêtes indépendantes


async function showPost (req, res) {
  try {
    const post = await db.posts.find(req.params.id)
    const [author, comments] = await Promise.all([
      post.author.find(),
      post.comments.find({ limit: 10 })
    ])
    res.render('posts/show', { post, author, comments })
  } catch (err) {
    handleError(err)
  }
}
            

awaiting


const { delay, limit, map } = require('awaiting')

async function superDuper (req, res) {
  try {
    // Simulate load
    await delay(500)
    // Promise timeout!
    const user = await limit(User.find(req.params.id), 2000)
    // Capped parallel async mapping
    const payloads = await map(req.params.urls, 3, fetchText)
    await user.processPayloads(payloads)
    res.json({ result: 'success' })
  } catch (err) {
    handleError(err)
  }
}
            

Anti-patterns

et mauvaises pratiques

Sérialisation inutile

Le risque, c’est de reproduire à tort les séquences 100% bloquantes des langages traditionnels, sans réfléchir :


async function listEntries (req, res) {
  try {
    // Here goes stupid…
    const entries = await Entry.getEntries(req.query)
    const entryCount = await Entry.count().exec()
    const tags = await Entry.tags()
    res.render('entries/index', { entries, entryCount, tags })
  } catch ({ message }) {
    req.flash('error',
      `Les bookmarks n’ont pu être affichés (${message})`)
    res.redirect('/')
  }
}
            

Sérialisation inutile

La solution

Paralléliser ce qui peut l’être


async function listEntries (req, res) {
  try {
    // Aaaah, much better.
    const [entries, entryCount, tags] = await Promise.all([
      Entry.getEntries(req.query),
      Entry.count().exec(),
      Entry.tags()
    ])
    res.render('entries/index', { entries, entryCount, tags })
  } catch ({ message }) {
    req.flash('error',
      `Les bookmarks n’ont pu être affichés (${message})`)
    res.redirect('/')
  }
}
            

return await


async function apiCall({ path, method, payload }) {
  // This `await` is useless and could "hurt" performance…
  return await fetch(`/api/v1/${path}`, {
    method,
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload)
  })
}
            

Aucun intérêt, puisque le résultat est de toutes façons déjà wrappé…

Itération asynchrone

mal maîtrisée / naïve


paths.forEach(
  async (path) => console.log((await fsStat(path)).size)
)
            

Ce sont des promesses :
elles sont ici parallèles, non ordonnées et non attendues.

Itération asynchrone

for-of-await : séquentiel


(async () => {
  for (const fileName of fileNames) {
    console.log((await fsStat(fileName)).size)
  }
})()
            

C’est un peu mieux (au moins, on attend la fin, et l’ordre est préservé), mais inutilement séquentiel : chaque appel n’a pas besoin des autres, et l’ordre interne des appels à stat est sans importance.

Itération asynchrone

await-all-map : parallèle


(async () => {
  const sizes = await Promise.all(fileNames.map(
    (fileName) => fsStat(fileName).then(({ size }) => size)
  ))
  sizes.forEach((size) => console.log(size))
})()
            

À préférer chaque fois qu’on peut paralléliser les traitements.

What else?

Vavavoooom!

(performance)

≈ 60% meilleur en natif que la transpilation à base de générateurs

Babel : utilisez latest-minimal (Node) ou env (iso), pas latest, qui transpilera tout en ES5 et passera donc par regenerator etc.

Babel + nodent : ajoutez fast-async pour ≈ 30% de boost.

Limitations

Les mêmes que les promesses : c’est du one-shot

Si vous voulez du multi-déclenché,
utilisez des event emitters ou des observables.

Et après ?

Async iterations / iterators (for-await, stade 3, ES2018 « sûr »)


for await (const line of readLines(filePath)) {
  console.log(line)
}
            

Observables natifs (stade 1, très prochainement stade 2, sans doute ES2019). RxJS suit de près pour être interopérable.


function commandKeys (element) {
  const keyCommands = { '38': 'up', '40': 'down' }

  return listen(element, 'keydown')
    .filter((event) => event.keyCode in keyCommands)
    .map((event) => keyCommands[event.keyCode])
}
            

Merci !

Et que Node.js soit avec vous.


Christophe Porteneuve

@porteneuve

Les slides sont sur bit.ly/deep-dive-async-await