Пишем микросервис на KoaJS 2 в стиле ES2017. Часть I: Такая разная ассинхронность

Koa v2

У Вас никогда не возникало желания переписать все с чистого листа, «забить» на совместимость и сделать все «по уму»? Скорее всего KoaJS создавался именно так. Этот фреймворк уже несколько лет разрабатывает команда Express. Экспресовцы про эти 2 фреймворка пишут так: Philosophically, Koa aims to «fix and replace node», whereas Express «augments node» [С филосовской точки зрения Koa стремится «пофиксить и заменить ноду» в то время как Express «расширяет ноду»].

Koa не обременен поддержкой legacy-кода, с первой строчки вы погружаетесь в мир современного ES6 (ES2015), а в версии 2 уже есть конструкции из будущего стандарта ES2017. В моей компании этот фреймворк в продакшене уже 2 года, один из проектов (AUTO.RIA) работает на нагрузке полмиллиона посетителей в день. Несмотря на свой уклон в сторону современных/экспериментальных стандартов фреймворк работает стабильнее Express и многих других фреймворков с CallBack-style подходом. Это обусловлено не самим фреймворком, а современными конструкциями JS, которые в нем применяются.

В этой статье я хочу поделиться своим опытом разработки на koa. В первой части будет описан сам фреймворк и немного теории по организации кода на нем, во второй мы создадим небольшой рест-сервис на koa2 и обойдем все грабли, на которые я уже наступил.

Немного теории


Давайте возьмем простой пример, напишем функцию, которая читает данные в объект из JSON-файла. Для наглядности будем обходиться без «reqiure ('my.json')»:
const fs = require('fs');
function readJSONSync(filename) {
    return JSON.parse(fs.readFileSync(filename, 'utf8'))
}
//...
try {
    console.log(readJSONSync('my.json'));
} catch (e) {
    console.log(e);
}

Какая бы проблема не случилась при вызове readJSONSync, мы обработаем это исключение. Тут все замечательно, но есть большой очевидный минус: эта фукция выполняется синхронно и заблокирует поток на все время выполнения чтения.

Попробуем решить эту задачу в nodejs style с помощью callback-функций:

const fs = require('fs');

function readJSON(filename, callback) {
    fs.readFile(filename, 'utf8', function (err, res) {
        if (err) return callback(err);
        try {
            res = JSON.parse(res);
            callback(null, res);
        } catch (ex) {
            callback(ex);
        }
    })
}

//...
readJSON('my.json', function (err, res) {
    if (err) {
        console.log(err);
    } else {
        console.log(res);
    }
})

Тут с ассинхронностью все хорошо, а вот удобство работы с кодом пострадало. Есть еще вероятность, что мы забудем проверить наличие ошибки 'if (err) return callback (err)' и при возникновении исключения при чтении файла все «вывалится», второе неудобство заключается в том, что мы уже погрузились на одну ступеньку в, так-называемый, callback hell. Если ассинхронных функций будет много, то вложенность будет расти и код будет читаться очень тяжело.

Что же, попробуем решить эту задачу более современным способом, оформим функцию readJSON промисом:

const fs = require('fs');

function readJSON(filename) {
    return new Promise(function(resolve,reject) {
	fs.readFile(filename,'utf8', function (err, res) {
	    if (err) reject(err);
	    try {
		res = JSON.parse(res);
		resolve(res);
	    } catch (e) {
		reject(e);
	    }
        })
    })
}

//...
readJSON('my.json').then(function (res) {
   console.log(res);
}, function(err) {
   console.log(err);
});

Этот подход немного прогрессивнее, т.к. большую сложную вложенность мы можем «развернуть» в цепочку then…then…then, выглядит это приблизительно так:

readJSON('my.json')
	.then(function (res) {
		console.log(res);
		return readJSON('my2.json')
	}).then(function (res) {
		console.log(res);
}).catch(function (err) {
		console.log(err);
	}
);

Это ситуацию, пока что, ощутимо не меняет, есть косметическое улучшение красоты кода, возможно, стало понятнее что за чем выполняется. Кардинально ситуацию изменило появление генераторов и библиотеки co, которые стали основой движка koa v1.
Пример:

const fs = require('fs'),
      co = require('co');

function readJSON(filename) {
    return function(fn) {
	fs.readFile(filename,'utf8', function (err, res) {
	    if (err) fn(err);
	    try {
		res = JSON.parse(res);
		fn(null,res);
	    } catch (e) {
		fn(e);
	    }
        })
    }
}

//...
co(function *(){
  console.log(yield readJSON('my.json'));
}).catch(function(err) {
   console.log(err);
});

В месте, где используется директива yield, происходит ожидание выполнения ассихронного readJSON. readJSON при этом необходимо немного переделать. Такое оформление кода получило название thunk-функция. Есть специальная библиотека, которая делает из функции, написанной в nodejs-style в thunk-функцию thunkify.
Что это нам дает? Самое главное — код в той части, где мы вызываем yield, выполняется последовательно, мы можем написать

  console.log(yield readJSON('my.json'));
  console.log(yield readJSON('my2.json'));

и получить последовательное выполнение сначала чтения 'my.json' потом 'my2.json'. А вот это уже «callback до свидания». Тут «некрасивость» в том, что мы используем особенность работы генераторов не по прямому назначению, thunk-функция это нечто нестандартное и переписывать все для koa в такой формат «не айс». Оказалось, не все так плохо, yield можно делать не только для thunk-функции, но и промису или даже масиву промисов или объекту с промисами.
Пример:
    console.log(
        yield {
            'myObj': readJSON('my.json'),
            'my2Obj': readJSON('my2.json')
        }
    );

Казалось, лучше уже не придумаешь, но придумали. Сделали так, чтоб все было «по прямому» назаначению. Знакомьтесь, Async Funtions:

import fs from 'fs'

function readJSON(filename) {
        return new Promise(function (resolve, reject) {
                fs.readFile(filename, 'utf8', function (err, res) {
                        if (err) reject(err);
                        try {
                                res = JSON.perse(res);
                                resolve(res)
                        } catch (e) {
                                reject(e)
                        }
                })
        })
}

//...
(async() => {
        try {
                console.log(await readJSON('my.json'))
        } catch (e) {
                console.log(e)
        }
})();

Не спешите запускать, без babel этот синтаксис ваша нода не поймет. Koa 2 работатет именно в таком стиле. Вы еще не поразбегались?

Давайте разберемся как работает этот «убийца колбеков»:

import fs from 'fs'

аналогично
var fs = require('fs')

с промисамы уже знакомы.

() => { }  — так обозначается «стрелочная функция», аналогична записи function () { }. У стрелочной функции есть небольшое отличие — контекст: this ссылается на объект, в котром инициализируется стрелочная функция.

async перед функцией указывает, что она ассинхронная, результатом такой функции будет тоже промис. Поскольку, в нашем случае, после выполнения этой функции там ничего делать не нужно, мы опустили вызов then или catch. Могло быть так, как показано ниже, и это тоже будет работать:

(async() => {
      console.log(await readJSON('my.json'))
})().catch (function(e) {
      console.log(e)
})

await это место, где надо подождать выполнения ассинхронной функции (промиса) и далее работать с результатом, который он вернул или обрабатывать исключение. В какой-то мере это напоминает yield у генераторов.

Теория закончилась — можем приступать к первому запуску KoaJS.

Знакомьтесь, koa

«Hello world» для koa:

const Koa = require('koa');
const app = new Koa();

// response
app.use(ctx => {
  ctx.body = 'Hello Koa';
});

app.listen(3000);

функцию, которая передается как аргумент в app.use принято называть middleware. Минималистично, не правда ли? В этом примере мы видим укороченный вариант записи этой функции. В терминологии Koa middleware может быть трех типов:

  • common function
  • async function
  • generatorFunction

Также с точки зрения фазы выполнения кода, middleware делится на две фазы: до (upstream) обработки запроса и после (downstream). Эти фазы разделяются функцией next, которая передается в middleware.

common function


// Middleware обычно получает 2 параметра (ctx, next), ctx это контекст запроса,
// next это функция которая будет выполнена в фазе 'downstream' этого middleware. Она возвращает промис, который можно зарезолвить с помощью фукции then и выполнить часть кода после того как запрос уже обработан.
app.use((ctx, next) => {
  const start = new Date();
  return next().then(() => {
    const ms = new Date() - start;
    console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
  });
});

async function (работает с транспайлером babel)


app.use(async (ctx, next) => {
  const start = new Date();
  await next();
  const ms = new Date() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

generatorFunction


В случае такого подхода необходимо подключить библиотеку co, которая начиная с версии 2.0 уже не является частью фреймворка:
app.use(co.wrap(function *(ctx, next) {
  const start = new Date();
  yield next();
  const ms = new Date() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
}));

Поддерживаются также legacy middleware от koa v1. Надеюсь, в вышестоящих примерах понятно, где upstream/downstream. (Если нет — пишите в комменты)

В контексте запроса ctx есть 2 важных для нас объекта request и response. В процессе написания middleware мы разберем некоторые свойства этих объектов, по указанных ссылкам вы можете получить полный перечень свойств и методов, которые можно использовать в своем приложении.

Пора переходить к практике, пока я не процитировал всю документацию по ECMAScript

Пишем свой первый middleware


В первом примере мы расширим функционал нашего «Hello world» и добавим в ответ дополнительный заголовок, в котором будет указано время обработки запроса, еще один middleware будет писать в лог все запросы к нашему приложению. Поехали:
const Koa = require('koa');
const app = new Koa();

// x-response-time
app.use(async function (ctx, next) {
  const start = new Date();
  await next();
  const ms = new Date() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

// logger
app.use(async function (ctx, next) {
  const start = new Date();
  await next();
  const ms = new Date() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});

// response
app.use(ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

Первый middleware сохраняет текущую дату и на этапе downstream пишет заголовок в ответ.
Второй делает то же самое, только пишет не в заголовок, а выводит на консоль.

Стоит отметить, что если в middleware не вызывается метод next, то все middleware, которые подключены после текущего, принимать участие в обработке запросов не будут.

При тестировании примера не забывайте подключить babel

Обработчик ошибок


C этим заданием koa справляется шикарно. Например, мы хотим в случае любой ошибки отвечать пользвателю в json-формате 500 ошибку и свойство message с информацией про ошибку.

Самым первым middleware пишем следующее:

app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    // will only respond with JSON
    ctx.status = err.statusCode || err.status || 500;
    ctx.body = {
      message: err.message
    };
  }
})

Все, можете попробовать в любом middleware бросить исключение с помощью 'throw new Error («My error»)' или спрвоцировать ошибку другим способом, она «всплывет» по цепочке к нашему обработчику и приложение ответит корректно.

Я думаю, что этих знаний нам должно хватить для создания небольшого REST-сервиса. Мы этим непременно займемся во второй части статьи, если, конечно, это кому-то интересно кроме меня.

Полезные ссылки


  • Примеры из статьи на gitHub. (не забудте про «npm install» после клонирования)
  • Koa 2 на github [eng]
  • Документация по API для Koa v. 2 [eng]
  • Проект спецификации Async Funtions будущего стандарта ES2017 [eng]

Комментарии (0)

© Habrahabr.ru