koa v2 によるAPIアプリケーションの実装例

koa

モバイルアプリサービス部の五十嵐です。

最近、KoaというNode.jsのWeb Frameworkを使ってAPIアプリケーションを実装しました。Koa自体は極めて薄いWeb Frameworkで、機能としてはRequestとResponseのContextと、それを取り回すMiddlewareのインターフェイスしかありません。koaを使ってアプリケーションを実装するには、このインターフェイスに従いMiddlewareを作成し、Middlewareの層を積み重ねていきます。このようなアーキテクチャのWeb Frameworkは初めてだったので最初は戸惑いましたが、なんとか自分なりのいい感じのアプリケーションができてきたので、実装例を紹介したいと思います。

環境

  • Node.js 8.4.0
  • koa 2.3.0

注意: koaは、v1とv2でMiddlewareのシグネチャが異なります。本記事は、v2についての記事です。

koaのコア

koaのコアであるMiddlewareについて説明します。Middlewareはctxとnextを引数に持つ関数です。async関数を使うこともできます。

async (ctx, next) => {}

ctxはRequestとResponseの情報を持ちます。nextは次のMiddlewareを呼び出す関数です。nextで呼び出されたMiddlewareには、呼び出し元のctxが引き継がれます。

実際の例を見てみましょう。以下のコードはkoaのドキュメントにあるサンプルコードです。

const Koa = require('koa');
const app = new Koa();

// x-response-time

app.use(async (ctx, next) => {
  const start = Date.now(); // --(1)
  await next();
  const ms = Date.now() - start;    // --(6)
  ctx.set('X-Response-Time', `${ms}ms`);    // --(7)
});

// logger

app.use(async (ctx, next) => {
  const start = Date.now(); // --(2)
  await next();
  const ms = Date.now() - start;    // --(4)
  console.log(`${ctx.method} ${ctx.url} - ${ms}`);  // --(5)
});

// response

app.use(async ctx => {
  ctx.body = 'Hello World'; // --(3)
});

app.listen(3000);

Koa - next generation web framework for node.js

app.use で渡されているのがMiddlewareの関数です。サーバがリクエストを受け付けると、最初に渡されたMiddlewareから順に処理が進みます。そして next() が呼ばれると、次のMiddlewareに処理が移ります。Middlewareの処理が終わると、呼び出し元の next() に処理が戻ります。実際に実行される順にコメントで番号を振ってみました。イメージできましたでしょうか?

公開されているMiddleware

koaに対応したMiddlewareは koajs/koa Wiki に載っています。koaのv2を使う場合は、 Supports v2 にチェックがあるものを選択します。

実際に使用したMiddlewareは以下の2つです。

koa-body-perser

koa-body-perserは、リクエストパラメータを ctx.request.body にセットしてくれるMiddlewareです。設定方法は app.use でMiddlewareを差し込むだけです。

const Koa = require('koa')
const app = new Koa()
const bodyParser = require('koa-bodyparser')

app
  .use(bodyParser())

koa-router

koa-routerは、URLパスによるルーティングを提供してくれるMiddlewareです。以下は実際のアプリケーションの例です。

const Koa = require('koa')
const app = new Koa()
const router = require('koa-router')()

const authorize = require('./middlewares/authorize.js')
const loginController = require('./controllers/login_controller.js')

router
  .post('/login', loginController.login)
  .post('/logout', authorize, loginController.logout)

app
  .use(router.routes())
  .use(router.allowedMethods())

ルーティングはメソッドチェーンで定義できます。メソッドにはHTTPのアクションを指定します。第一引数にURLのパスを指定し、第二引数以降にそのパスに適応するMiddlewareを実行順に設定します。

このコードの例では、 /login にリクエストされた時は loginController.login が実行され、/logout にリクエストされた時は authorize が実行されたあと、 loginController.login が実行されます。

認証

作成したアプリケーションではアクセストークンによる認証がありましたので、アクセストークンを検証する独自のMiddlewareを作成しました。以下は実際のアプリケーションのコードです。

module.exports = async (ctx, next) => {
  const accessToken = ctx.request.header.authorization
  if (accessToken === undefined) {
    ctx.throw(401, 'Unauthorized')
  }

  const userId = await アクセストークンからUserIdを取得する処理(accessToken)
  if (userId === undefined) {
    ctx.throw(401, 'Unauthorized')
  }

  ctx.user = await ctx.model.users.findById(userId)

  await next()
}

リクエストのHeaderにアクセストークンがない場合や、アクセストークンが不正な場合は ctx.throw で例外を発生させます。うまく行った場合は ctx.user にユーザ情報をセットすることで、 next() で呼び出されるMiddlewareの中で ctx.user を参照することができます。

このMiddlewareを、koa-routerと組み合わせて認証が必要なエンドポイントにだけ設定しました。

エラーハンドリング

koaの標準機能として、Middleware内で発生したthrowがcatchされなかった場合は500エラーとしてレスポンスされます。また、ctx.throwされた場合は、ステータスコードとメッセージを設定できます。

作成したアプリケーションでは、エラーレスポンスをJSON形式にしたかったので、以下のMiddlewareを作成しました。

module.exports = async (ctx, next) => {
  await next()
    .catch((err) => {
      ctx.logger.warn(err)

      // statusが設定されている場合は、ctx.throwされたエラーなのでエラーメッセージをレスポンスに設定する
      if (err.status) {
        ctx.status = err.status
        ctx.body = {
          message: err.message
        }
      } else {
        ctx.status = 500
        ctx.body = {
          message: 'Internal Server Error'
        }
      }
    })
}

このコードは、koa-json-errorというMiddlewareの実装を参考にしました。ポイントは next().catch をカスケードしていることです。そして、このMiddlewareを他のすべてのMiddlewareより先に設定します。

const Koa = require('koa')
const app = new Koa()
const jsonError = require('./middlewares/json_error.js')

app
  .use(jsonError)
  .use(...)

そうすることで、このMiddlewareの next() は他のすべてのMiddlewareのthrowをcatchできることになります。

また、ctxのスコープ外でエラーが発生した場合は、独自に定義したエラークラスをthrowし、ctxのスコープに入ったところでエラークラスをctx.throwに変換するようにしました。

以下のコードは、独自に定義したエラークラスとエラー変換の例です。

class InvalidParamaterError extends Error {}
class UserDuplicatedError extends Error {}

module.exports = {
  InvalidParamaterError: InvalidParamaterError, // 入力チェックエラー
  UserDuplicatedError: UserDuplicatedError, //  ユーザーが既に登録済み
}
const error = require('../lib/error.js')

module.exports = {
  create: async (ctx, next) => {
    const params = ctx.request.body

    await ユーザ登録処理(params.id, params.password)
      .catch(err => { transferErrorToContext(ctx, err) })
    }

    // 処理は続く...
  }
}

// エラークラスをコンテキストに変換する
const transferErrorToContext = (ctx, err) => {
  switch (err.constructor.name) {
    case 'InvalidParamaterError':
      return ctx.throw(400, 'パラメータが正しくありません。')
    case 'UserDuplicatedError':
      return ctx.throw(400, 'ユーザが既に登録されています。')
    default:
      throw err
  }
}

ユーザ登録処理 の中はctxのスコープ外なので、エラーが起きた時は独自に定義したエラークラスをthrowします。 transferErrorToContext の中で、特定のエラークラスだけハンドリングして ctx.throw に変換します。それ以外のエラーについては再度throwします。

ロギング

koaの機能にログに関する機能はないので、実用的なログを出力するには独自でロギングの仕組みを作る必要があります。作成したアプリケーションでは、以下を実現するMiddlewareを作成しました。

  • ログフォーマットを統一する(ログに出力する項目は以下の通り)
    • タイムスタンプ
    • ログレベル
    • トレースID(リクエストごとに一意なID)
    • メッセージ
  • トレースIDはX-Request-Idと対応させる
  • sequelizeのSQLログにも同じログフォーマットを適用する

以下は作成したMiddlewareのコードです。

const uuidv4 = require('uuid/v4')
const log4js = require('log4js')
const Model = require('../models/index.js')

module.exports = async (ctx, next) => {
  const requestId = ctx.request.get('X-Request-Id') || uuidv4()

  // ResponseヘッダーにX-Request-Idを設定する
  ctx.set('X-Request-Id', requestId)

  // アプリケーションからログ出力する時は、ctx.loggerを利用する
  log4js.configure(logConfig(requestId))
  ctx.logger = log4js.getLogger()

  // sequelizeのloggerをctx.loggerに設定する
  ctx.model = new Model(ctx)

  ctx.logger.info('Start.')
  ctx.logger.info(ctx.request.url)

  await next()

  ctx.logger.info('End.')
}

const logConfig = (requestId) => {
  return {
    appenders: {
      'out': {
        type: 'stdout',
        layout: {
          type: 'pattern',
          pattern: '[%d] [%p] [%x{requestId}] : %m',
          tokens: { requestId: requestId }
        }
      }
    },
    categories: { default: { appenders: ['out'], level: 'debug' } }
  }
}

ログフォーマットを統一するために、 log4jsを使用しました。フォーマットの設定にトレースIDを設定し、作成した logger オブジェクトを ctx に挿入します。こうすることで、ctxのスコープ内であれば ctx.logger でログ出力することができます。ctxのスコープ外をどうするかは課題ですが、今のところそれほど複雑な処理はないので例外ログだけで事足りています。

トレースIDは、リクエストのHeaderの X-Request-Id から取得し、無ければUUIDv4を設定します。また、レスポンスのHeaderにもX-Request-Id として設定します。

sequelizeは複数のRDSに対応したORMです。sequelizeはデフォルトでは、実行したSQLをconsole.logで出力します。今回はsequelizeのログ出力も作成したloggerを通して出力するようにしました。

以下は、sequelizeが生成するindex.jsを修正してクラスにし、コンストラクターでctxを渡せるようにしたコードです。(ログ出力に関係ないコードは省いています)

var Sequelize = require('sequelize')
var env = process.env.NODE_ENV || 'development'
var config = require(__dirname + '/../config/config.json')[env]

class Db {
  constructor (ctx = null) {
    const db = {}
    let sequelize

    if (ctx) {
      config.logging = (msg) => {
        ctx.logger.debug(msg)
      }
    }

    if (config.use_env_variable) {
      sequelize = new Sequelize(process.env[config.use_env_variable])
    } else {
      sequelize = new Sequelize(config.database, config.username, config.password, config)
    }

    // 以下略...
  }
}

module.exports = Db

ポイントは、 config.logging に 引数を1つ受ける関数を設定することです。その関数の中で任意のloggerを使用し、ログ出力することができます。

実際のログは以下のように出力されます。

[2017-09-05 19:24:17.949] [INFO] [4c36334b-a638-46b3-bd70-a52f64ebe31f] : Start.
[2017-09-05 19:24:17.952] [INFO] [4c36334b-a638-46b3-bd70-a52f64ebe31f] : /login
[2017-09-05 19:24:17.974] [INFO] [4c36334b-a638-46b3-bd70-a52f64ebe31f] : 省略
[2017-09-05 19:24:22.322] [INFO] [4c36334b-a638-46b3-bd70-a52f64ebe31f] : 省略
[2017-09-05 19:24:22.382] [DEBUG] [4c36334b-a638-46b3-bd70-a52f64ebe31f] : Executing (default): SELECT 省略 LIMIT 1;
[2017-09-05 19:24:22.404] [DEBUG] [4c36334b-a638-46b3-bd70-a52f64ebe31f] : Executing (default): SELECT 省略;
[2017-09-05 19:24:22.501] [INFO] [4c36334b-a638-46b3-bd70-a52f64ebe31f] : End.

テスト

テストは Mochasupertest を使って、「Middlewareのテスト」と「End to Endのテスト」を書きました。今回はモジュールレベルの単体テストは書いていませんが、書く場合はMiddlewareに実装が書いてあるとテストが書きにくいので、Middlewareは極力薄くして、実装はクラスなどに分離するとテストが書きやすいと思います。

Middlewareのテスト

Middlewareのテストは、Middlewareの動作に最低限必要なMiddlewareだけを設定したkoaアプリケーションを作成し、supertestにHttp.serverのリスナーを渡します。

const assert = require('assert')
const Koa = require('koa')

const supertest = require('supertest')
const bodyParser = require('koa-bodyparser')
const authorize = require('../../middlewares/authorize.js')
const initialize = require('../../middlewares/initialize.js')

const app = new Koa()

const server = app
  .use(initialize)
  .use(bodyParser())
  .use(authorize)
  .use(async ctx => { ctx.body = 'This is test.' })

const request = supertest(server.listen())

describe('Authorization', () => {
  context('Authorizationヘッダーがない場合', () => {
    it('401が返ること', async () => {
      const res = await request
        .get('/')

      assert.equal(res.status, 401)
    })
  })

  // 以下略...

End to Endのテスト

End to Endのテストは全てのMiddlewareを設定したkoaアプリケーションを作成し、supertestにHttp.serverのリスナーを渡します。

const assert = require('assert')
const supertest = require('supertest')
const server = require('../../server.js')

const request = supertest(server.listen())

describe('POST /login', () => {
  context('リクエストパラメータにidがない場合', () => {
    it('レスポンスが400エラーであること', async () => {
      const res = await request
        .post('/login')
        .send({password: 'password'})

      assert.equal(res.status, 400)
    })
  })

  // 以下略...

アプリケーションの構成

最後にアプリケーションの全体構成を紹介します。

.
├── app.js -- (3)
├── config -- (7)
├── controllers -- (4)
├── lib -- (6)
├── middlewares -- (5)
├── migrations -- (7)
├── models -- (7)
├── node_modules -- (1)
├── package.json -- (1)
├── seeders -- (7)
├── server.js -- (2)
├── test -- (8)
└── yarn.lock -- (1)

(1) アプリケーションの構成管理ファイル・ディレクトリ

(2) koaのメインプログラム。koaにMiddlewareを組み込む。

(3) Http.server起動用のプログラム。テストでは(2)だけを使用するため、HTTPサーバの起動プログラムは別で用意しました。

(4) エンドポイントごとの処理を行うMiddlewareを配置するディレクトリ

(5) 共通して利用するMiddlewareを配置するディレクトリ

(6) 共通して利用するライブラリを配置するディレクトリ

(7) RDSのORMである sequelize が生成するディレクトリ

(8) テストを配置するディレクトリ

まとめ

koaは提供されている機能が極めて少ないため、実運用に耐えうるアプリケーションを書くには、今回紹介したような機能を組み込む必要があります。はじめは機能がなさすぎてどうしようかと思いましたが、次第に自分なりのフレームワークを作っているような感じになってきたのが楽しかったです。

AWS Cloud Roadshow 2017 福岡