4 августа 2016 в 11:24

Пишем микросервис на 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.parse(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-сервиса. Мы этим непременно займемся во второй части статьи, если, конечно, это кому-то интересно кроме меня.

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


Олег Черний @apelsyn
карма
178,1
рейтинг 0,0
Разработчик
Самое читаемое Разработка

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

  • 0
    Пишите еще, интересно. Текущий проект заказчик захотел на ноде и мой выбор пал на express 4.x так как в koa не был уверен (посчитал не совсем готовым).
    • 0

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


      У вас будет шикарный фундамент, и выбор middleware для реализации роутинга, шаблонизации, сесии и т.д. Про некоторые полезные middleware я напишу во второй части.

      • +1
        Что скажете о Restify, он вообще изначально кажется проектировался для REST, ну а это уже совсем близко к микросервисам. Правда я сам минус вижу в том, что Restify не так быстро обновляет свой код под новые фичи JS… хотя это может и не минус
        • 0

          Это хороший фреймворк — на нем создавались самые первые версии микросервисов нашей компании, мы использовали его совместно с модулем async. Некоторые из этих сервисов по сей день обслуживают ненагруженную часть сайта, которую мы еще не успели переписать. Но это CallBack-style фреймворк и они с Koa находятся в разных весовых категориях.

  • +1

    Пишу небольшой проект на koa2 (как раз rest), так что тема интересная.
    Надеюсь во второй части будет информации побольше, чем просто из текущей документации.

  • –1

    https://github.com/nin-jin/async-js


    Вы дошли до 4 уровня, продолжайте движение :-)

    • –1

      async/await это то что через год станет частью JavaScript, остальные уровни не из нашей игры :)

      • 0

        Игра одна и та же. Через год медленные костыли станут чуть менее медленными, но всё теми же костылями. У async/await нет совершенно никаких преимуществ перед волокнами.

        • 0
          Кажется, показалось, но, вроде, 22 миллисекунды в async/await — это при переводе в switch вместе с regenerator runtime? Так-то async/await переводится в чистые генераторы, и выигрыш составляет такой же, как и fibers, только языком поддерживается (будет, надеюсь) полностью, а отсюда и все вытекающие оптимизации, разве нет?
          • 0
            Async/await transformed to generators by Babel
            • +1

              async/await, кстати, уже нативно появились в chrome 52 (под флагом). Думаю можно актуализировать тесты без babel-я.


              • 0

                Ну, как седьмая нода зарелизится, так можно будет и обновить :-)

        • 0

          Если я правильно понял, вы хотете сказать что организация ECMA собирается добавить в стандарт "костыли"? Звучит это как-то, мягко говоря, не убедительно. Откуда вы знаете, что с какой скоростью будет реализовано?


          В Koa используется async/await, я Вам его не навязываю, просто рассказваю о фреймворке. Вам нравиться модуль Fibers? Я против него ничего не имею — используйте.

          • +2

            Эта организация уже и так надобавляла уже кучу костылей и продолжает в том же духе. Чего только стоят несколько api для работы с пропертями (getters|setters, defineProperty/defineProperties, Object.create, Symbol, Reflection, Proxy). А ведь всего-то надо было лишь позволить перегружать операторы и получать идентификатор объекта.


            По секрету скажу, что async/await, generators и прочие stackless сопрограммы в машинных кодах реализуются через тот же switch с соответствующими пенальти по производительности и сложностями оптимизации. Найдёте другой способ — дайте знать :-) fibers же не даёт никакого пенальти на вызов функции, только на переключение стеков, что является быстрой и относительно редкой операцией.


            А я использую тот же express и враппер, запускающий обработчик в отдельном волокне:


            express.get( '/userInfo', $jin.sync2middle( req => {
            
                var body = {
                   auth : requestInfoFromAuthServer() ,
                   profile : requestProfileFromUserStorageServer() ,
                }
            
                return { status : 'ok' , type : 'text/json' , body }
            } ) )
  • НЛО прилетело и опубликовало эту надпись здесь
    • НЛО прилетело и опубликовало эту надпись здесь
  • 0
    Тоже когда-то рассматривал koa, но отсутствие некоторых библиотек побудило использовать старый добрый express.

    А чем express нестабилен?
    • 0

      Есть модуль koa-connect или koa-express. Оба позволяют подключать модули от express. Они спроектированы для версии koa 1.0 но через koa-convert можно подключить к Koa2, если есть такая необходимость. Правда не 100% експресовских middleware будут работать по такой схеме, т.к. в Koa некоторые объекты могут называться по-другому.


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


      По сравнению с koa у любого callbeck-style фреймворка (не только express) есть слабое место в обработке ошибок. Даже если вы в своем коде сделали все правильно, то ошибка может возникнуть во внешнем модуле и, как итог, ваш процесс падает, конечно там есть решение через домены, но они уж точно не добавляют красоты и наглядности вашему приложению. У koa такой проблемы нет, вы можете отлавливать все через try/catch как при работе сихронного кода, как показано в примере статьи. На koa уронить процесс значительно труднее, даже если вы сами не обработаете исключение, его обработает koa и не упадет.

      • +2

        Домены ещё и deprecated. Емнип, из-за потенциальных утечек памяти.


        А стектрейсы при использовании генераторов просто "чудесные".

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.