
Это продожение статьи Пишем микросервис на KoaJS 2 в стиле ES2017. Часть I: Такая разная ассинхронность. Постараюсь угодить начинающему разработчику, который хочет расстаться с express, но не знает как. Кода будет много, текста мало — я ленивый но отзывчивый.
Koa минималистичен, в сам фреймворк не входит даже роутер. Сейчас быстренько его доукомплектуем такими модулями, которые от программиста требуют минимального напряжения.
Постановка задачи
Банальный пример: Есть таблица товаров (products) на MySQL. Наша задача — дать возможность добавлять/удалять/изменять товары в этой таблице через REST-сервис, который нам предстоит написать.
Начнем
Создадим 2 папки ./config и ./app. В корневой папке сервиса будет только один файл, который подключает babel и наше приложение из папки ./app
require('babel-core/register'); const app = require('./app');
Настройки для babel вынесены в .babelrc
Основной файл нашего приложения будет выглядеть так:
import Koa from 'koa'; import config from 'config'; import err from './middleware/error'; import {routes, allowedMethods} from './middleware/routes'; const app = new Koa(); app.use(err); app.use(routes()); app.use(allowedMethods()); app.listen(config.server.port, function () { console.log('%s listening at port %d', config.app.name, config.server.port); });
Для хранения конфигурационных настроек я рекомендую модуль config. Он позволяет удобно организовать конфигурацию, вплоть до отдельного инстанса.
Наши кастомные middleware будем создавать в папке ./middleware.
Для того, чтоб информацию про ошибки отдавать в JSON-формате, напишем ./middleware/error.js
export default 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 }; } }
Роуты можно было поместить в основной файл, но тогда код будет казаться сложнее.
Костяк сервиса готов.
Пишем роуты
Для роутов есть много модулей, в том числе с поддержкой koa 2, я предпочитаю koa-router, сильные стороны этого модуля рассмотрим на нашем примере:
import Router from 'koa-router'; import product from '../models/product'; import convert from 'koa-convert'; import KoaBody from 'koa-body'; const router = new Router(), koaBody = convert(KoaBody()); router .get('/product', async (ctx, next) => { ctx.body = await product.getAll() }) .get('/product/:id', async (ctx, next) => { let result = await product.get(ctx.params.id); if (result) { ctx.body = result } else { ctx.status = 204 } }) .post('/product', koaBody, async (ctx, next) => { ctx.status = 201; ctx.body = await product.create(ctx.request.body) }) .put('/product/:id', koaBody, async (ctx, next) => { ctx.status = 204; await product.update(ctx.params.id, ctx.request.body); }) .delete('/product/:id', async (ctx, next) => { ctx.status = 204; await product.delete(ctx.params.id); }); export function routes () { return router.routes() } export function allowedMethods () { return router.allowedMethods() }
UPD: По замечанию от rumkin, переделал последнюю строку с экспортом — там было лаконично, но не по феншую. Ну и, соответственно, поправил import в ./app/index.js
Мы подключаем модель product, о которой поговорим чуть ниже, а также модуль koa-body, который парсит тело post-запроса в объект. С помощью koa-convert мы сконвертируем koa-body в middleware для koa 2.
В самом простом случае, роут выглядит предсказуемо:
.get('/product', async (ctx, next) => { ctx.body = await product.getAll() })
В случае get-запроса по адресу /product, получаем из модели все записи и передаем их ctx.body для передачи в JSON клиенту. Все необходимые заголовки koa установит сам.
А вот POST-запрос обрабатывается интреснее — в роут, начиная со второго аргумента, можно добавлять неограниченное количество middleware. В нашем случае это дает нам возможность подключать koa-body для получения тела запроса, перед тем, как эти данные будут обработаны следующим middleware.
По роутам все, если у вас возникли еще вопросы, задавайте их в комментах.
Какие ошибки и при каких случаях возвращает REST нигде не описано однозначно, «можно так, можно эдак». Я закодил так, как удобно было бы мне, поэтому если у Вас есть к этой части замечания, то я с удовольсвием их рассмотрю.
Создаем модель «product»
В качестве БД я выбрал MySQL, просто потому, что она была у меня «под руками».
import query from 'mysql-query-promise'; import config from 'config'; const tableName = config.product.tableName; const crud = { getAll: async () => { return query(`SELECT * from ${tableName}`); }, get: async (id) => { let products = await query(`SELECT * FROM ${tableName} WHERE id=?`,[Number(id)]); return products[0]; }, create: async function ({ id, name, price = 0, currency = 'UAH' }) { let product = {name: String(name), price: Number(price), currency: String(currency)}; if (id > 0) product.id = Number(id); let result = await query(`INSERT INTO ${tableName} SET ? ON DUPLICATE KEY UPDATE ?`,[product,product]); if (result.insertId) id = result.insertId; return crud.get(id); }, update: async (id, product)=> { if (typeof product === 'object') { let uProduct = {}; if (product.hasOwnProperty('name')) uProduct.name = String(product.name); if (product.hasOwnProperty('price')) uProduct.price = Number(product.price); if (product.hasOwnProperty('currency')) uProduct.currency = String(product.currency); let result = await query(`UPDATE ${tableName} SET ? WHERE id=?`,[uProduct, Number(id)]); return result.affectedRows; } }, delete: async (id) => { let result = await query(`DELETE FROM ${tableName} WHERE id=?`,[Number(id)]); return result.affectedRows; } }; export default crud;
Модуль mysql-query-promise написан в нашей компании, я не могу сказать, что это шедевр инжененерной мисли, так как он жестко привязан к модулю config. Но в нашем случае он применим.
Простые методы get, delete, put нет смысла коментировать, там код говорит сам за себя, а вот про POST немного раскажу. Я не использовал стрелочную фунуцию, т.к. применил расширенные возможности по передаче параметров из стандарта ES6/ES2015. Таким способом, как в примере мы можем организовать передачу параметров, не заботясь о порядке их следования, кроме того, можно установить «значения по умолчанию» для незаданных пераметров.
Тестируем
Все, наш сервис готов, можно его запустить и потестировать с коммандной строки:
curl -XPOST "127.0.0.1:3001/product" -d '{"id":1,"name":"Test","price":1}' -H 'Content-Type: application/json' curl -XGET "127.0.0.1:3001/product" curl -XPUT "127.0.0.1:3001/product/1" -d '{"name":"Test1"}' -H 'Content-Type: application/json' curl -XGET "127.0.0.1:3001/product/1" curl -XDELETE "127.0.0.1:3001/product/1"
Все работает, буду рад вашим предложениям, которые помогут сделать код еще более простым и понятным.
Вместо заключения
Я понимаю, что мало кто из почитателей express, restify и др. устоявшихся фреймворков готов все бросить и кодить на фреймворке, у которого ядро почитателей только формируется, и который достаточно радикально меняется от версии к версии. К koa написано пока немного middleware и практически нет документации на русском. Но несмотря на это я свой выбор сделал еще 2 года назад, когда узнал о Koa из случайного коментария на хабре.
В комментах к первой части статьи пользователь fawer написал о том, что async/await уже доступен в chrome 52 без babel. Будущее уже близко, не пропустите его!
