Getting started koa and sequelize with docker-compose

KoaとSequelize雑に試したメモ

Circle CI 2.0でDB連携したテスト実行がしたかったので 色んなことを雑に扱ってKoa / Sequelizeを使ったAPIを実装したメモ

Koaのセットアップ

  • node v7.6から、デフォルトでasync / await使えるようになってるから、babelいらなかった
  • middleware形式はexpressでもやってた
  • await next(); が呼び出されると、次のmiddlewareに処理が渡るらしい

    • 下流のmiddlewareが処理を終えると、上流middlewareに戻ってくる
    • await 付け忘れると、NotFoundしか返らなくなりエラーにもならないので辛い

dockerize

  • Dockerfileを作成する

    • node.jsのdockerizeドキュメント
    • node_modulesのキャッシュとか工夫できそう
    • 参考
    • COPYが面倒だったら、dockerignore作成するとよさそう
    • ignoreした以外を全部COPYしておく
    • CMDがシングルクォーテーションだとうまくいかない
FROM node:carbon

WORKDIR /usr/src/app

COPY package*.json ./

RUN yarn install

COPY ./src ./src

EXPOSE 3010

CMD ["node", "src/app.js"]
  • docker imageをビルド
// imageのbuild
docker build -t ykokw/circleci-practice .

// imageの確認
$ docker images
REPOSITORY                 TAG                 IMAGE ID            CREATED              SIZE
ykokw/circleci-practice    latest              e82c86452777        About a minute ago   688MB

(イメージ大きいのもalpine使うとかで工夫できる)

  • コンテナ実行
docker run -p 3010:3010 -d ykokw/circleci-practice

この状態でcurl叩くとレスポンスが返ってくる

  • コンテナ停止
docker stop <container name>

Sequelizeのセットアップ

docker-composeを用意してDB連携する

  • mysqlコンテナのドキュメント
  • とりあえず、入門なのでDB情報とか全部ベタ書き..?

    • docker secrets(多分、/run/secrets/配下に値が設定されたファイルがあるみたい)が使えるらしい
    • どうやってデータを保持するかあとで考える
    • .sqlファイルで初期設定できるみたいだが、sequelizeのmigrationでやる予定
version: '3'
services:
  web:
    build: .
    ports:
    - 3010:3010
    links:
    - mysql
  mysql:
    image: mysql:latest
    ports:
      - 3306:3306
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
      MYSQL_DATABASE: "library"

マイグレーションの用意

configファイル

  • sequelize-cliが読み込んでDB接続に利用する

  • config/config.json

    • developmentのhostがdocker-composeの設定に合わせたものになっている
    • test環境はCircle CI用
{
  "development": {
    "username": "root",
    "password": "",
    "database": "library",
    "host": "mysql",
    "dialect": "mysql"
  },
  "test": {
    "username": "root",
    "password": "",
    "database": "library",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "database_production",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
}

マイグレーションファイル作成

"use strict";

module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.createTable("books", {
      id: { type: Sequelize.INTEGER.UNSIGNED, primaryKey: true, autoIncrement: true },
      title: { type: Sequelize.STRING, allowNull: false },
      createdAt: { type: Sequelize.DATE, defaultValue: Sequelize.NOW},
      updatedAt: { type: Sequelize.DATE, defaultValue: Sequelize.NOW}
    });
  },

  down: (queryInterface, Sequelize) => {
    return queryInterface.dropTable("books");
  }
};

マイグレーション実行

sequelize db:migrate

docker-compose環境でのマイグレーション実行

  • docker-compose.yml
version: '3'
services:
  web:
    build: .
    ports:
    - 3010:3010
    depends_on:
      - "mysql"
    command: ["node", "wait-for-db.js", "sequelize", "db:migrate", "&&", "node", "src/app.js"]
    environment:
      - "NODE_ENV=development"
  mysql:
    image: mysql:latest
    ports:
      - 3306:3306
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
      MYSQL_DATABASE: "library"

docker-composeを利用するときは、DBのセットアップを待ってからアプリ起動やマイグレーションを行う必要がある。 depends_onでビルド順の指定はできるが、DBのセットアップ完了前にアプリコンテナが起動しがちである。 (参考: dockerのドキュメント)

上のドキュメントでは、mysqlやpostgresのクライアントがアプリコンテナにある前提だったので sequelize使うように書いてみた wait-for-db.js を、アプリコンテナのcommandに指定している。

const { exec } = require("child_process");
const Sequelize = require("sequelize");

// sequelizeのセットアップ
const sequelize = new Sequelize("library", "root", "", {
  host: process.env.NODE_ENV === "development" ? "mysql" : "127.0.0.1",
  dialect: "mysql",
  pool: {
    max: 5
  }
});

// DBセットアップが完了した後のコマンドを用意
const command = process.argv.reduce((cmd, arg, i) => {
  if (i > 2) cmd = cmd + ` ${arg}`;
  return cmd;
}, process.argv[2]);

let count = 0;
const i = setInterval(() => {
  count++;
  if (count > 60) {
    clearInterval(i)
    return;
  }
  sequelize
    .authenticate() // DB接続の確認
    .then(() => {
      clearInterval(i);
      return exec(command, (err, stdout, stderr) => {
        if (err) {
          console.error(err);
          sequelize.close(); //closeしないとスクリプトが終了しない
          return;
        }
        console.log(stdout);
        console.log(stderr);
        sequelize.close();
        return;
      });
    })
    .catch(() => {
      console.log("unreachable");
    });
}, 1000);

APIサーバーの改修

  • src/app.js

    • ルーティングライブラリにkoa-routerを利用

      • koa-routeだとasync / await対応していない(シンプルすぎる)
    • ここでsequelizeのmodel定義してるのもアンチパターンだと思う

      • sequelizeのmodelの使い方まだ良くわかっていない
    • Circle CI試す用なので、ひどいAPI IFになっている

      • 引数なしの /register にPOSTでアクセスしてきたら固定データをDBに保存
      • その結果を返すAPI
const Koa = require('koa');
const Router = require('koa-router');
const Sequelize = require('sequelize');
const Book = require('./book');

const app = new Koa();
const router = new Router();
const book = new Book();

const sequelize = new Sequelize("library", "root", "", {
  host: process.env.NODE_ENV === "development" ? "mysql" : "127.0.0.1",
  dialect: "mysql",
  pool: {
    max: 5
  }
});

app.context.sequelize = sequelize;

app.use(async (ctx, next) => {
    const { sequelize } = ctx;
    const Book = sequelize.define('books', {
        id: {
            type: Sequelize.INTEGER.UNSIGNED,
            primaryKey: true,
            autoIncrement: true,
        },
        title: {
            type: Sequelize.STRING
        }
    });
    sequelize.models.Book = Book;
    await next();
})

router.post('/register', async (ctx, next) => {
  const b = await book.register(ctx);
  ctx.b = b;
  await next();
},
async (ctx, next) => {
  ctx.status = 200;
  ctx.body = ctx.b;
});

app.use(router.routes());

app.listen(3010);
  • src/book.js

    • 実際のDB連携の処理を別ファイルに用意

      • koaのcontextからsequelizeを取り出してDB操作
class Book {
    async register (ctx) {
        // ctx.body = 'register';
        const { sequelize } = ctx;
        const { Book } = sequelize.models;
        const book = await Book.create({
          title: 'test'
        }).catch(err => {
            console.log(`err: ${err}`);
            ctx.body = err.toString()
        });
        return book;
    }
}

module.exports = Book;