kkty’s blog

node.jsとかvue.jsとかの話が多いと思います

ContentfulとexpressとNuxt.jsで簡単なブログを作ってみる

Contentfulというサービスを知る機会があったので、それを用いてブログを作ってみました。 Contentfulは、ユーザー向けのページを提供しないCMS(ヘッドレスCMS)というもので、コンテンツの追加・編集画面やデータベース、そしてそのコンテンツを利用するためのAPIなどを提供しています。ベルリンの会社で、Lyftとかも顧客にいるみたいです。

今回は

  • コンテンツの追加・管理 -> Contentful
  • Contentfulからデータを取得して整形 -> express
  • ユーザー向けの画面を生成 -> Nuxt.js といった形で簡単なブログを作ってみたいと思います。

コードはこちらで公開しています。

github.com

Contentful

まずはContentful周りの作業をします。

アカウントの作成

以下のページからContentfulのアカウントを作成できます。 少し使うだけなら無料です。

www.contentful.com

Spaceの作成

その後、Spaceというものを作ります。データベースの名前みたいなものです。今回は blogという名前の物を作ります。

f:id:k-kty:20181212113005p:plain

Content Modelの作成

次に、Content Modelというものを作ります。データベースで言うテーブルのようなものです。

Content Modelでは、複数のFieldを定義していきます。種類は以下のようなものがあります。

f:id:k-kty:20181212113132p:plain

今回はこのようにフィールドを定義していきました。

  • 記事ID ... Short text
  • タイトル ... Short text
  • 日付 ... Date & time
  • 本文 ... Rich text

Contentの作成

Content Modelの定義が終わったら、それに従ってContentを作成していきます。

編集画面はこのようになっています。シンプルでとても使いやすいです。

f:id:k-kty:20181212113632p:plain

express

次に、「Contentfulからデータを取得し、整形してレスポンスとして返す」APIをexpressで作成します。

ファイル構成

  • package.json
  • config.js 設定ファイル
  • contentful.js Contentfulのライブラリのラッパー
  • index.js ルーティングの設定・処理の定義
  • models.js 記事クラス(Post)の定義

package.json

{
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "contentful": "^7.0.5",
    "cors": "^2.8.5",
    "express": "^4.16.4"
  }
}

config.js

こちらに必要な値はContentfulで取得できます。

module.exports = {
  CONTENTFUL_SPACE: '...',
  CONTENTFUL_ACCESS_TOKEN: '...',
}

contentful.js

const contentful = require('contentful');

const {
  CONTENTFUL_SPACE,
  CONTENTFUL_ACCESS_TOKEN,
} = require('./config');

const client = contentful.createClient({
  space: CONTENTFUL_SPACE,
  accessToken: CONTENTFUL_ACCESS_TOKEN,
});

module.exports = client;

models.js

Rich text形式のデータをHTMLに変換するためのコードも含んでいます。

Rich text形式のデータについてはこちらを参照してください。

Rich Text – Contentful

const contentful = require('./contentful');

class Post {
  constructor({
    id,
    title,
    date,
    content,
  }) {
    this.id = id;
    this.title = title;
    this.date = date;
    this.content = content;
  }

  get contentHtml() {
    return Post.getHtml(this.content);
  }

  static getHtml(content) {
    if (content.nodeType === 'text') {
      const bold = content.marks.filter(i => i.type === 'bold').length > 0;
      const italic = content.marks.filter(i => i.type === 'italic').length > 0;
      const underline = content.marks.filter(i => i.type === 'underline').length > 0;

      const styles = {};

      if (bold) {
        styles['font-weight'] = 'bold';
      }

      if (italic) {
        styles['font-style'] = 'italic';
      }

      if (underline) {
        styles['text-decoration'] = 'underline';
      }

      if (Object.keys(styles).length) {
        return `<span style="${Object.entries(styles).map(i => `${i[0]}:${i[1]};`).join('')}">${content.value}</span>`;
      } else {
        return content.value;
      }
    }

    if (content.nodeType === 'hr') {
      return '<hr/>';
    }

    let tag = '';
    const attributes = {};

    if (content.nodeType === 'heading-1') {
      tag = 'h1';
    } else if (content.nodeType === 'heading-2') {
      tag = 'h2';
    } else if (content.nodeType === 'heading-3') {
      tag = 'h3';
    } else if (content.nodeType === 'heading-4') {
      tag = 'h4';
    } else if (content.nodeType === 'heading-5') {
      tag = 'h5';
    } else if (content.nodeType === 'heading-6') {
      tag = 'h6';
    } else if (content.nodeType === 'paragraph') {
      tag = 'p';
    } else if (content.nodeType === 'document') {
      tag = 'div';
    } else if (content.nodeType === 'unordered-list') {
      tag = 'ul';
    } else if (content.nodeType === 'ordered-list') {
      tag = 'ol';
    } else if (content.nodeType === 'list-item') {
      tag = 'li';
    } else if (content.nodeType === 'blockquote') {
      tag = 'blockquote';
    } else if (content.nodeType === 'hyperlink') {
      tag = 'a';
      attributes.href = content.data.uri;
    } else if (content.nodeType === 'embedded-asset-block') {
      tag = 'img';
      attributes.src = content.data.target.fields.file.url;
      attributes.alt = content.data.target.fields.title;
    }

    return `<${tag}${Object.entries(attributes).map(i => ` ${i[0]}="${i[1]}"`).join('')}>${content.content.map(Post.getHtml).join('')}</${tag}>`;
  }

  static async findById(id) {
    return contentful.getEntries({
      'fields.id': id,
      'content_type': 'post',
    })
      .then(i => i.items)
      .then((entries) => {
        if (!entries.length) {
          return null;
        }

        return new Post(entries[0].fields);
      });
  }

  static async list(params) {
    return contentful.getEntries(params)
      .then(i => i.items)
      .then(entries => entries.map(entry => new Post(entry.fields)));
  }
};

module.exports = {
  Post,
};

index.js

  • /posts で記事の一覧を取得
  • /posts/<記事id> で記事のデータを取得

といったようにしています。

const express = require('express');
const cors = require('cors');

const { Post } = require('./models');

const app = express();

app.use(cors());

app.get('/posts', async (req, res) => {
  const posts = await Post.list(req.query)
    .then(posts => posts.map(post => ({
      id: post.id,
      title: post.title,
      date: post.date,
    })));

  res.send(posts);
});

app.get('/posts/:id', async (req, res) => {
  const post = await Post.findById(req.params.id);

  // 投稿が存在しない場合には404エラー
  if (!post) {
    res.status(404).send({
      error: 'not found',
    });

    return;
  }

  res.send({
    id: post.id,
    title: post.title,
    date: post.date,
    content: post.contentHtml,
  });
});

app.listen(4000);

動かしてみる

以上のディレクトリで npm start を実行すると、サーバーが localhost:4000 で立ち上がります。

http://localhost:4000/posts で投稿のリストを取得します。以下のようなレスポンスが返ってきます。

[{"id":"what-is-contentful","title":"Contentfulを試してみた","date":"2018-12-01T00:00+09:00"}]

http://localhost:4000/posts/what-is-contentful で投稿の詳細を取得します。以下のようなレスポンスが返ってきます。

{"id":"what-is-contentful","title":"Contentfulを試してみた","date":"2018-12-01T00:00+09:00","content":"<div><h1>Contentfulとは</h1><p><a href=\"https://www.contentful.com/\">Contentful</a>は<span style=\"font-weight:bold;\">ヘッドレスCMS</span>です。</p><p>以下のように画像を挿入したりもできます。</p><img src=\"//images.ctfassets.net/gu6l9tv1vpsd/2kMHqVs6JS6wMkOqOWIGMU/fe98c0c3a6f207a4d9286171e348bd6b/____________________________2018-12-10_1.50.46.png\" alt=\"スクリーンショット 2018-12-10 1.50.46\"></img><p></p><p></p><p></p><p></p></div>"}

Nuxt.js

次に、expressからデータを取得して表示するページをNuxtで作成します。

フィアル構成

  • layouts/default.vue
  • pages/index.vue
  • pages/posts/_id.vue
  • nuxt.config.js
  • package.json

package.json

{
  "scripts": {
    "start": "nuxt"
  },
  "dependencies": {
    "@nuxtjs/axios": "^5.3.6",
    "nuxt": "^2.3.4"
  }
}

nuxt.config.js

Nuxtの設定ファイルです。axiosモジュールを使うようにしています。

module.exports = {
  modules: [ '@nuxtjs/axios' ],
};

layouts/default.vue

デフォルトのレイアウトを定義します。

<template>
  <div class="page">
    <section class="header">
      <nuxt-link to="/">
        <h1>Contentful + Express + Nuxt で作るブログ</h1>
      </nuxt-link>
    </section>
    <section class="main">
      <nuxt/>
    </section>
  </div>
</template>

<style scoped>
.page {
  max-width: 720px;
  margin: 0 auto;
  padding: 20px;
}

.header {
  padding: 20px 0;
}

.header a {
  text-decoration: none;
}

.header h1 {
  font-size: 18px;
  /* text-align: center; */
}

.main {

}
</style>

<style>
* {
  box-sizing: border-box;
  margin: 0;
  color: #333;
}
</style>

pages/index.vue

トップページ(記事の一覧ページ)です。今回は簡単のためにコンポーネント化などはしません。

<template>
  <div>
    <ul class="post-list">
      <template v-for="(post, idx) in postList">
        <li :key="'post'+idx">
          <nuxt-link :to="'/posts/' + post.id">
            <span v-text="post.title"/>
          </nuxt-link>
        </li>
      </template>
    </ul>
  </div>
</template>

<script>
export default {
  async asyncData({ app }) {
    const postList = await app.$axios.get('http://localhost:4000/posts')
      .then(i => i.data);

    return {
      postList,
    };
  },
}
</script>

<style scoped>
.post-list {
  margin: 0;
  padding: 0;
}

.post-list li {
  list-style: none;
}
</style>

pages/posts/_id.vue

記事の詳細ページです。存在しない記事を見ようとした場合には404エラーになるようにしています。

<template>
  <div>
    <p
      v-text="formatDate(post.date)"
      class="post-date"/>
    <h1
      v-text="post.title"
      class="post-title"/>
    <div
      v-html="post.content"
      class="post-content"/>
  </div>
</template>

<script>
export default {
  async asyncData({ app, params, error }) {
    const post = await app.$axios.get(`http://localhost:4000/posts/${params.id}`)
      .then(i => i.data)
      .catch(() => null);

    if (!post) {
      return error({
        statusCode: 404,
      });
    }

    return {
      post,
    };
  },
  methods: {
    formatDate(date) {
      return date.split('T')[0].split('-').map(Number).join('/');
    },
  },
};
</script>

<style scoped>
.post-date {
  font-size: 12px;
}

.post-title {
  font-size: 24px;
}

.post-content {
  padding: 20px 0;
}
</style>

<style>
.post-content h1 {
  font-size: 20px;
  margin: 5px 0;
}

.post-content h2 {
  font-size: 20px;
  margin: 5px 0;
}

.post-content h3 {
  font-size: 18px;
  margin: 5px 0;
}

.post-content h4 {
  font-size: 18px;
  margin: 5px 0;
}

.post-content h5 {
  font-size: 16px;
  margin: 5px 0;
}

.post-content h6 {
  font-size: 14px;
  margin: 5px 0;
}

.post-content p {
  font-size: 14px;
  margin: 5px 0;
}

.post-content img {
  max-width: 100%;
  margin: 5px 0;
}
</style>

動かしてみる

APIを起動させたまま、このディレクトリで npm start を実行すると localhost:3000 でサーバーが立ち上がります。

以下のように、記事の一覧ページ、記事の詳細ページを表示することができます。

f:id:k-kty:20181212115831p:plain

f:id:k-kty:20181212115856p:plain

Contentful、相当使いやすいですね。疑問点などあったらコメントなどでご質問ください。

node.jsでaws s3のデータを読み書きする

aws-sdkパッケージを使います。以下のコマンドでインストールします。

npm i aws-sdk

このドキュメントは https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html で見ることができます。適宜参照しましょう。

また、別途S3の読み書き権限のあるアクセスキー(とシークレットキー)を発行しておきます。

準備が終わったら、以下のように書き始めます。今後s3オブジェクトを操作することになります。

const aws = require("aws-sdk");

const S3_ACCESS_KEY = '...';
const S3_SECRET_KEY = '...';

const s3 = new aws.S3({
  apiVersion: '2006-03-01',
  accessKeyId: S3_ACCESS_KEY,
  secretAccessKey: S3_SECRET_KEY,
});

オブジェクトを列挙するための関数を作ってみます。

const listObjects = params => new Promise((resolve, reject) => {
  s3.listObjectsV2(params, (err, data) => {
    if (err) reject(err);
    else resolve(data.Contents);
  });
});

以下のようにすれば、'some-bucket'バケット内のオブジェクトを3つ列挙してくれます。並び順は辞書順のはずです。

listObjects({
  Bucket: 'some-bucket',
  MaxKeys: 3,
})
  .then((objectList) => {
    // 何らかの処理
  });

ここで、objectListはETag, Key, LastModified, Sizeなどをキーとして持つオブジェクトの配列です。

次に、バケット名・キー名からファイルを読み出す関数を作ってみます。ストリームを用いるケースもあると思いますが、今回は省略します。

const getObject = (bucket, key) => new Promise((resolve, reject) => {
  s3.getObject({
    Bucket: bucket,
    Key: key
  }, (err, data) => {
    if (err) reject(err);
    else resolve(data);
  });
});

以下のようにすれば、'some-bucket'内の、'some-key'という名前のオブジェクトのデータを取得できます。

getObject('some-bucket', 'some-key')
  .then((object) => {
    // 何らかの処理
  });

ここでobjectはBody, ETag, LastModified, ContentLengthなどをキーとして持つオブジェクトです。

そして最後に、バケットに新しいデータを追加する関数を作ってみます。

const putObject = (bucket, key, data) => new Promise((resolve, reject) => {
  s3.putObject({
    Body: data,
    Bucket: bucket,
    Key: key
  }, (err, data) => {
    if (err) reject(err);
    else resolve(data);
  });
});

以下のようにして、'some-bucket'内に'some-key'という名前のファイルを作成できます。中身は'some-content'となります。

putObject('some-bucket', 'some-key', Buffer.from('some-content'))
  .then(() => {
    // 何らかの処理
  });

dockerのコンテナ・ボリュームを掃除する

逐一きれいにしていくのが一番なのですが、dockerを用いていろいろやっていると、不要なファイルが残っていたりします。

そういうときには以下のコマンドを実行します。

docker rm `docker ps -qf "status=exited"`
docker volume rm `docker volume ls -qf "dangling=true"`

一行目のコマンドでは、停止しているコンテナを列挙し(docker ps...の部分)、それをdocker rmで削除しています。

また、二行目のコマンドでは、コンテナから参照されていない(≒不要な)ボリュームを列挙して(docker volume ls ...)、それをdocker volume rmで削除しています。

knex.jsを用いてnode.jsでmysqlを使う

knex.jsとは

Node.js向けのSQLクエリービルダーです。

以下のデータベースに対応しています。

  • Postgres
  • MSSQL
  • MySQL
  • MariaDB
  • SQLite3
  • Oracle
  • Amazon Redshift

実際に使ってみる

今回はmysqlとのセットでknex.jsを使ってみます。

まず、今回使用するmysqlをdockerで用意します。以下のコマンドで、mysqlの動いているコンテナが起動します。

docker run -e MYSQL_ROOT_PASSWORD=password -d -p 3306:3306 mysql:5 --character-set-server=utf8mb4

そして、以下のコマンドで、'db'という名前のデータベースを作成します。

echo 'CREATE DATABASE db' | mysql -h 127.0.0.1 -u root -ppassword

以下、このデータベースをknex.jsから操作することにします。

適当なディレクトリを作り、knexとmysqlをインストールします。

npm i knex mysql

同一ディレクトリ内で作業します。

knexをインポートし、clientを定義します。

const knex = require('knex');

const client = knex({
  client: 'mysql',
  connection: {
    host: '127.0.0.1',
    user: 'root',
    password: 'password',
    database: 'db',
  },  
});

以下で、'tbl'という名前のテーブルが定義できます。

client.schema.createTable('tbl', function (table) {
  table.increments();
  table.string('name');
  table.timestamps();
})
  .then(console.log);

これによって吐き出されたSQL文はこのようなものでした。賢いですね。

making query: create table `tbl` (`id` int unsigned not null auto_increment primary key, `name` varchar(255), `created_at` datetime, `updated_at` datetime)

なおconsoleへの出力は以下のような感じになります。

[ OkPacket {
    fieldCount: 0,
    affectedRows: 0,
    insertId: 0,
    serverStatus: 2,
    warningCount: 0,
    message: '',
    protocol41: true,
    changedRows: 0 },
  undefined ]

以下のようなコードで、'tbl'テーブルにレコードを挿入できます。2行挿入するためにPromise.allを使っています。

Promise.all([
  client('tbl').insert({
    name: 'hoge',
    created_at: client.fn.now(),
    updated_at: client.fn.now(),
  }),
  client('tbl').insert({
    name: 'foo',
    created_at: client.fn.now(),
    updated_at: client.fn.now(),
  }),
])
  .then(console.log);

consoleへの出力は以下のようになりました。1と2の順番が入れ替わるかもしれません。

[ [ 1 ], [ 2 ] ]

以下のようなコードで'tbl'テーブルからデータを取得できます。

client('tbl')
  .select('name', 'created_at', 'updated_at')
  .then(console.log);

consoleへの出力は以下のようになりました。mysqlモジュールの挙動と同じですね。

[ RowDataPacket {
    id: 1,
    name: 'hoge',
    created_at: 2018-11-06T14:12:45.000Z,
    updated_at: 2018-11-06T14:12:45.000Z },
  RowDataPacket {
    id: 2,
    name: 'foo',
    created_at: 2018-11-06T14:12:45.000Z,
    updated_at: 2018-11-06T14:12:45.000Z } ]

キリがないのでここで紹介する関数はこれくらいにしますが、もちろん他にも多数の関数があります。https://knexjs.org/ を参照してください。

Dockerを用いて一行でmysqlサーバーを動かす

以下のコマンドを実行します。

docker run -e MYSQL_ROOT_PASSWORD=password -d -p 3306:3306 mysql --character-set-server=utf8mb4

これだけで、dockerのmysqlサーバーの動いているコンテナが立ち上がります。なお、以下のように設定しています。

  • rootのパスワードは'password'
  • 文字コードはutf8mb4
  • 3306番ポートをフォワード

以下のコマンドで、シェルから実際に接続できるはずです。

mysql -u root -ppassword

nginxでリバースプロキシを使ってみる

リバースプロキシとは

クライアントからWEBサーバーへのリクエストを経由させるサーバーのことです。

クライアント -> WEBサーバー という状況を、クライアント -> リバースプロキシ -> WEBサーバー のようにします。

この冗長化により、

  • セキュリティの強化
  • 負荷分散
  • SSLの対応
  • キャッシュ

などが便利になります。

実際に使ってみる

言葉だけではわかりにくいので、実際にDockerを用いて実験してみます。

次のように、3つのファイルを作ります。

  • docker-compose.yml
  • app.js
  • nginx.conf

以下、それぞれ見ていきます。

app.js

const express = require('express');
const app = express();
app.get('/', (req, res) => res.send('Hello World!'));
app.listen(3000);

簡単なexpressアプリケーションです。

ポート3000にアクセスすると'Hello World!'を返します。

nginx.conf

user  nginx;
worker_processes  1;

pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;

    server {
        listen       80;
        server_name  _;

        location / {
            proxy_pass http://app:3000;
        }
    }
}

nginxの設定ファイルです。

proxy_passの部分でリバースプロキシを設定しています。すべてのリクエストを http://app:3000 に転送しています。appというのはexpressのアプリケーションが動いているコンテナのホスト名です。(docker-compose.ymlで設定しています)

docker-compose.yml

version: '3'
services:
  app:
    image: node:11.1.0
    hostname: app
    volumes:
      - ./app.js:/src/app.js
    working_dir: /src
    command: sh -c "npm i express && node app.js"

  nginx:
    image: nginx:1.15.5
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    environment:
      - NGINX_PORT=80

dockerの設定ファイルです。

appというコンテナでexpressアプリケーションを実行し、nginxというコンテナでnginxサーバーを起動しています。volumesを用いて今回作成したファイルをマウントしています。

hostname: appという部分で、コンテナにappというホスト名を付与しています。これにより、nginxというコンテナからappというホスト名で該当コンテナにアクセスできるようになります。

確認

docker-compose upコマンドで、expressアプリケーション、nginxサーバーが起動します。

http://localhost/にアクセスすると、'Hello World!'と表示されるはずです。