Пишем микросервис на KoaJS 2 в стиле ES2017. Часть II: Минималистичный REST

    Koa v2

    Это продожение статьи Пишем микросервис на 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. Будущее уже близко, не пропустите его!

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


    Метки:
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 23
    • 0
      Очень лаконичный код получается. Для микросервисов самое то!
      • 0

        Модуль-роутер, должен возвращать фабрику, а не инстанс роутера.

        • 0

          Вы правы, хотел минимизировать. Поправил код, теперь экспортрую 2 функции.

          • 0

            Нет, я имел в виду другое. Модуль должен экспортировать такой же middleware, как и любой другой middleware. Пример на gist.

            • 0

              Так в новой редакции у меня так и есть. Там роутер производит 2 разных middleware: 1 сами роуты, 2 заглушка, котрая правильно отвечает на вызов недопустимого метода. Но ее теретически можно не подключать, от этого REST сильно в функциональности не потеряет. В первой редакции это было не так, там эксортился инстанс роута, а он сам по себе middleware не является. Для генерации основного middleware у него надо вызвать метод router.routes()

              • 0

                По сути модель лучше перенести в options, тогда появится возможность передать модель настроенную на работу с другой коллекцией (например). Потому что сейчас роутер напрямую зависим от файла 'models/router', чего лучше избегать в модульном приложении.

          • 0
            Поясните, пожалуйста, почему?
            • 0

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

              • +2
                Имхо, вы оверинженерите.

                > Это нужно для того, чтобы сделать приложение по-настоящему модульным. Иначе получается монолитное приложение разбитое на несколько файлов.

                По-моему, это одно и тоже. Полагаю, что под «по-настоящему модульным», вы имели в виду «переиспользуемые модули». Но здесь нечего переиспользовать. options пока что не нужен и не известно, понадобится ли еще.

                Я начал этот разговор, потому что, мне кажется, это распространенная проблема в сообществе nodejs. Все радостно убежали от ООП к функциональщине со словами «ООП нужно использовать там, где он нужен». Но вместо классов теперь оверинженерят на функциях. Часто можно обойтись и без дополнительной функции. Тем более, что классы хотя бы давали дополнительные точки расширения. Замыкания же насильно все инкапсулируют, не давая даже в простейших случаях подправить поведение функции. И при этом иногда приходится долго скакать по функциям, чтобы в итоге понять, что последовательность действий была весьма линейна.

                Только поймите правильно, я не говорю, что именно этот ваш код сложно будет читать. Тут особо «скакать» не придется. Я говорю, что здесь можно не городить лишнего, потому что профит от этого пока что очень размыт. А это и есть оверинжениринг.
                • 0

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

          • 0
            Спасибо, очень интересно, возьму на заметку.
            Кстати по поводу кода в CRUD можно же без стрелочных функций обойтись:
            async create({ id, name, price = 0, currency = 'UAH' }) { ... }
            
            • 0
              Вы к boolean таким же способом приводите?
              • 0
                Ну да
                console.log(Boolean(1)) // true

                Немного сложнее с датой, обычно для этого используют Moment.js:
                console.log(moment("2016-08-07 22:48:37",'YYYY-MM-DD HH:mm:ss').toDate() instanceof Date) // true


                • 0
                  На всякий случай, moment.js хоть и очень удобный, но очень медленный (на некоторых операциях). У меня был случай, что до 90% времени генерации страницы тратилось на обработку дат в moment.js. Его стоит использовать очень осторожно.
              • 0

                Разве ES 2017 уже приняли?

                • 0
                  Нет не принят, из ES 2017 тут используется только синтаксис async/await, который находиться в стадии 'Stage 3 («Candidate»)', ожидаемое время стадии 'Stage 4 («Finished»)' конец ноября, до принятия «Async Functions»|осталось ждать несколько месяцев.
                  Все остальные синтаксические конструкции из стандарта ES 2015.
                  • 0

                    Имелся в виду ноябрь 2015, когда, из-за отсутствия второй реализации в браузере, асинхронные функции на четвёртую стадию и в ES2016 так и не попали. Как уже писал в комментариях к другому топику, на четвёртую стадию и в ES2017 асинхронные функции вывели 28 июля.

                • 0
                  Вы реально считаете это rest-микросервисом??
                  GET /product — без пагинации, без вывода нужных полей, а сразу все…
                  Сохранение данных модели без валидации.
                  Про модель Product я вообще молчу.
                  • 0
                    GET /product — без пагинации, без вывода нужных полей, а сразу все…

                    А вы что хотели чтоб я вам здесь фреймворк написал? В статье столько кода, сколько нужно для демонстрации подхода Koa 2.


                    Сохранение данных модели без валидации.

                    Вы, видимо, еще очень начинающий программист — там все есть. Если это не так напишите мне SQL-инъекцию для этого сервиса.


                    Про модель Product я вообще молчу.

                    Да вы не молчите, пишите, не стесняйтесь, может мы разберемся где у вас проблемы.

                  • 0

                    Я бы еще добавил ответы ошибок в виде json, если accept содержит application/json. Очень удобным вариантом ответа является такой:


                    {
                        "error": {
                            "code": "wrong_token",
                            "message": "Token not found, expired or already used",
                            "data": {
                                "tokenId": "327a595f-d493-498e-8c89-e3a8c24adc5f"
                            }
                        }
                    }

                    code совместно с data удобно использовать для формирования сообщения об ошибке пользователю (особенно в многоязычных приложениях). А message подходит для вывода в консоль.

                    • 0
                      А вы можете посоветовать хороший подакшн рэди так сказать ОRМ под js, а то все это напоминает пхп лет 10 назад только на js?
                      • 0

                        В мире nodejs самая популярная ORM для mysql это драйвер MySQL :).


                        У себя в компании мы не используем ORM. В этой статье мы говорим про микросервис, у которого, как правило, количество кода и таблиц небольшое, сам микросервис сильно "заточен" под базу с которой он работает. От него требуеться быстродействие и минимальное потребление ресурсов.


                        Вот некоторые ORM, на которые когда-то смотрел:


                        • 0
                          Спасибо. Офтоп. Есть такой сервис (http://www.js-data.io). Как вы думаете может ли это быть частично заменой обычному REST api?
                          Клиент работает с моделями, либа конвертит это в REST запросы к серверу.

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