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