
У Вас никогда не возникало желания переписать все с чистого листа, «забить» на совместимость и сделать все «по уму»? Скорее всего 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-сервиса. Мы этим непременно займемся во второй части статьи, если, конечно, это кому-то интересно кроме меня.
