JavaScript
Node.js
Heroku
Express
reactjs

Express.js + React.js + Heroku でWEB サービス Shuffle List をリリースしました

WEB サービス Shuffle List をリリースしました。

サービスについてご興味があればこちら、こちら をご覧いただける嬉しいです。
本記事は技術的な部分を紹介するものになります。
ソースコードもリリース時の状態の公開できる部分だけ こちら にあげています。

対象

 WEB 開発に詳しい方には特に得るものはない内容かもしれません。JavaScript を覚えたけど WEB サービスをどういう構成で作ろうか考えている方の助けになれば幸いです。
主にサービスを作り始めた当初の構想とその後の妥協、その理由について書いていきます。

サービスの構成

 サービスを作る前は誰しも大きなことを考えがちだと思います。企画的には「このサービスで世界を変えるんだ!」エンジニアリング的には「技術的にカッコよく、モダンでこれまでにない試みをしたい!」しかし、僕の力では数々の妥協が必要でした。そのため、当初考えていた構成とは異なるものになってしまいました。そのこと自体は特段悪いとは思わないのですが、論理的に避けたというよりは調査の手間や学習から逃げた場合が多かったかもしれません。

 過去の自分に言ってあげることがあるとすれば、「必要になったらその都度学ぶというやり方は容量の悪い君には難しいかもしれないから、使えそうなものは別途時間を作って触っておくといいと思うよ」ということです。

目次

  1. 当初考えていた構成と実際
  2. まずは骨子となる WEB フレームワーク
  3. データベースはデプロイ先次第、Postgres + Redis + Heroku が快適
  4. WEB API 流行の技術を使ってみたかったけれど
  5. フロントエンド Redux は使わず SPA でもない React
  6. その他依存モジュール
  7. 最後に
  8. 参考

開発を始める前の計画と実際

以下は僕が考えていたカッコイイ構成と実際のものです。

- 考えていた 実際
WEB フレームワーク Koa.js Express.js
データベース MySQL or MongoDB, Redis Postgres, Redis
デプロイ先 Google Cloud Platform only Heroku + Google Cloud Storage
WEB API GraphQL (正確にはクエリ言語) jsonwebtoken で認証する REST API
フロントエンド React , Redux , Apollo React , Semantic-UI

まず骨子となる WEB フレームワーク

 WEB サービスを作ろうというのですから WEB フレームワークを選ぶのがまず最初でしょう。ですが、個人で WEB 開発をする際には結局自分が一番慣れ親しんでいる言語のデファクトの WEB フレームワークを使うしかないでしょう。ここを適材適所で選べるようになるには上級者にならないといけないと思います。

 とは言っても、Node.js での WEB 開発が他の言語と比べてめちゃくちゃ非効率な可能性を恐れて他の言語のフレームワークをいくつか触ってはみました。結果 JavaScript の細かい部品を組み合わせてすぐに捨てたり変えたりできる良さを発見し、JavaScript への愛が深まることになりました。

 また、Node.js の WEB フレームワークも Express.js の次世代である Koa.js を使いたかったのですが日本語の情報量や既存のノウハウを考えての妥協し、最初に学んだフレームワークである Express.js を使うことにしました。地力が低いと最新のツールを使うことができなくて悔しいです。

データベースはデプロイ先次第、Postgres + Redis + Heroku が快適

 データベースはデプロイ先を選んだ時点で決まりました。GCP を使うことを考えていたのですが Google Cloud SQL の料金が有料 Heroku-Postgres とさほど変わらないこと、設定のやりやすさなどの理由で慣れ親しんだ Heroku Postgres(Hobby Basic) + Heroku Redis(free) + Heroku(Hobby) にしました。この組み合わせが Node.js で WEB アプリをクイックスタートするのに快適すぎてなかなか抜け出せません。
ちなみに ORM はデファクトの Sequelize です。

 画像のストレージは Google Cloud Storage です。
画像のストレージはやったことがなく不安でしたが、Google のサンプル実装を参考に以下のように書くだけですみました。使いわませそうです。

storage.js
const format = require('util').format;
const Storage = require('@google-cloud/storage');
const config = require('../config');
const storage = new Storage({ keyFilename: config.KEYFILENAMEPATH });
const bucket = storage.bucket(process.env.BUCKET_NAME || config.BUCKET_NAME);

/** 
 * @return {pictureSrc}
 */

module.exports = (fileBuffer, fileName) => {
  return new Promise((resolve, reject) => {
    const blob = bucket.file(fileName);
    const blobStream = blob.createWriteStream();
    let pictureSrc = null;

    blobStream.on('error', (err) => {
      return reject(err);
    });

    blobStream.on('finish', () => {
      pictureSrc = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
      return resolve(pictureSrc);
    });

    blobStream.end(fileBuffer);
  });
};

WEB API 流行の技術を使ってみたかったけれど

 REST API の次のパラダイムとして GraphQL が流行してます。この流行にのって是非とも新技術を使ってカッコイイ構成にしたい!ところが実際は REST のようなものに落ち着きました。

 GraphQL にしたかった理由は新しいということもありますが、Facebook が出している GraphQL の実装が Node.js でリリースされていることを知り、これを使って WEB API を楽に実装できるならば、Node.js を使うモチベーションが高まると考えたからです。

 早速 GraphQL の良さそうなチュートリアルのサイトがあったので写経してみました。https://www.howtographql.com/

 ある程度終えると GraphQL が僕が勝手に想像していたものとは違うことに気づきました。想像はデータベースと同期してリクエストをクエリ言語で記述して投げれば返えしてくれるというもので API を作る必要すらないようなもの、でしたが今思えばそんな都合のいいもののはずはなく。クエリも query と mutation という形でかっちり定義し、その中身のデータ型もそれぞれ定義が必要でした。(これは本来はメリットかもしれません)
 WEB API を作るのも、クライアントから API を呼び出す処理を書くのも同じ開発者の場合であれば、楽になるという類のものではないという感覚でした。この GraphQL に関する感想は的を外している可能性が大いにあるので受け流す程度でお願いします。念のため。
 言いたいこととしては付け焼き刃で学習して、即プロダクトとして適応し使えるものではなかったということです。まとまった時間をとってまた GraphQL には再挑戦してみたいです。

 WEB API は jsonwebtoken で認証する REST API のようなものです。

以下はフォローボタンから呼び出すことを想定した WEB API の一例です。
GET POST PUT DELETE をそのまま Read Create Update Delete に対応させています。

api/follows.js
const express = require('express');
const router = express.Router();
const apiTokenDecoder = require('../../../apiTokenDecoder');
const apiTokenEnsurer = require('../../../apiTokenEnsurer');
const Follow = require('../../../../models/follow');

router.get('/', apiTokenEnsurer, async (req, res, next) => {
  const decodedApiToken = apiTokenDecoder(req);
  if (!decodedApiToken) {
    res.json({ status: 'NG', message: 'Api token not correct.' });
    return;
  }
  try {
    const followlist = await Follow.findAll({ where: { userId: decodedApiToken.userId } });
    res.json(followlist);
  } catch (err) {
    console.error(err);
    res.json({ status: 'NG', message: 'Database error.' });
  }
});

router.post('/', apiTokenEnsurer, async (req, res, next) => {
  const decodedApiToken = apiTokenDecoder(req);
  if (!decodedApiToken) {
    res.json({ status: 'NG', message: 'Api token not correct.' });
    return;
  }

  try {
    const followUserId = req.body.followUserId;
    console.log(followUserId);
    const followUser = await Follow.create({ followUserId: followUserId, userId: decodedApiToken.userId });
    res.json({ status: 'OK', message: 'FollowUser add', followUser: followUser });
  } catch (err) {
    console.error(err);
    res.json({ status: 'NG', message: 'Database error.' });
  }

});

router.delete('/', apiTokenEnsurer, async (req, res, next) => {
  const decodedApiToken = apiTokenDecoder(req);
  if (!decodedApiToken) {
    res.json({ status: 'NG', message: 'Api token not correct.' });
    return;
  }
  try {
    const followUserId = req.body.followUserId;
    await Follow.destroy({ where: { followUserId: followUserId, userId: decodedApiToken.userId } });
    res.json({ statsu: 'OK', message: 'unfollow' });
  } catch (e) {
    console.error(e);
    res.json({ statsu: 'NG', message: 'Datebase error' });
  }
});

module.exports = router;

 ブラウザからの呼び出しは fetch api を簡単にラップしたものを書いて使っています。もっとたくさんのことをするようなら Ajax ライブラリを使った方がいいかもしれませんが、この程度なら必要ないと判断しました。最初は fetch api をそのまま使って header など都度書いて使っていたのですが、モジュールに切り分けたことで書く量がグッと減って気持ちよかったです。
 他にもライブラリを一部書き換えて使ったりとまったくの初心者の頃はライブラリにおんぶにだっこでしたが、少し意識が変わったような気がします。サービス開発をする際はあまりブラックボックスを増やしたくないですから、そのおかげかもしれません。もちろんライブラリを使った方がいい場面の方が多いとは思います。

lib/fetch.js
export default class FetchAPI {

  constructor(url, apiToken) {
    this.url = url;
    this.apiToken = apiToken;
  }

  async get(query = '/') {
    try {
      if (!this.apiToken) return { message: 'not apiToken' };
      const headers = this.headers(this.apiToken);
      const res = await fetch(this.url + query, { method: 'GET', headers: headers });
      return await res.json();
    } catch (err) {
      console.error(err);
      return err;
    }
  }

  async post(body) {
    try {
      if (!this.apiToken) return { message: 'not apiToken' };
      const res = await fetch(this.url, { method: 'POST', headers: this.headers(this.apiToken), body: JSON.stringify(body) });
      return await res.json();
    } catch (err) {
      console.error(err);
      return err;
    }
  }

  async postForm(body) {
    try {
      if (!this.apiToken) return { message: 'not apiToken' };
      const res = await fetch(this.url, { method: 'POST', headers: this.formHeader(this.apiToken), body: body });
      return await res.json();
    } catch (err) {
      console.error(err);
      return err;
    }
  }

  async put(body) {
    try {
      if (!this.apiToken) return { message: 'not apiToken' };
      const res = await fetch(this.url, { method: 'PUT', headers: this.headers(this.apiToken), body: JSON.stringify(body) });
      return await res.json();
    } catch (err) {
      console.error(err);
      return err;
    }
  }

  async putForm(body) {
    try {
      if (!this.apiToken) return { message: 'not apiToken' };
      const res = await fetch(this.url, { method: 'PUT', headers: this.formHeader(this.apiToken), body: body });
      return await res.json();
    } catch (err) {
      console.error(err);
      return err;
    }
  }

  async delete(body) {
    try {
      if (!this.apiToken) return { message: 'not apiToken' };
      const res = await fetch(this.url, { method: 'DELETE', headers: this.headers(this.apiToken), body: JSON.stringify(body) });
      return await res.json();
    } catch (err) {
      console.error(err);
      return err;
    }
  }

  headers(apiToken) {
    return {
      Authorization: `Bearer ${apiToken}`,
      'Content-Type': 'application/json'
    };
  }

  formHeader(apiToken) {
    return {
      Authorization: `Bearer ${apiToken}`
    };
  }

};

フロントエンド Redux を使わず SPA もせず React を使用

 React は Redux とセットで使うのが定番になっていると思います。しかし、それは SPA 前提だったり、大きなアプリを想定していたりと自分のアプリには不要だと判断しました。なのでモーションや Ajax を使用する部品作りのために React を使っています。

 ですが、思ったより複雑で重複の多いコードになってしまってどうするか途中で少し悩みました。テストも書けてません。Redux などを使って書き直した方がいいかもしれません。もしそうだとしたらこれは Redux の習得を渋ってしまったツケです。情けない。

UI ライブラリは Semantic-UI です。ドキュメントとサンプルがとても充実していておすすめです。

依存モジュール

依存関係はこのような形です。

package.json
{
  "name": "shuffle",
  "version": "0.9.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www",
    "build": "./node_modules/.bin/webpack --config webpack.config.js",
    "watch": "./node_modules/.bin/webpack --progress --watch --config webpack.config.js",
    "test": "node_modules/.bin/mocha"
  },
  "dependencies": {
    "@google-cloud/storage": "^1.7.0",
    "babel-preset-stage-2": "^6.24.1",
    "body-parser": "~1.17.1",
    "connect-redis": "^3.3.3",
    "cookie-parser": "~1.4.3",
    "debug": "~2.6.3",
    "express": "~4.15.2",
    "express-paginate": "^1.0.0",
    "express-session": "^1.15.6",
    "helmet": "^3.12.1",
    "jquery": "^3.3.1",
    "jsonwebtoken": "^8.2.2",
    "mocha": "^5.2.0",
    "moment": "^2.22.2",
    "morgan": "~1.8.1",
    "multer": "^1.3.0",
    "passport": "^0.4.0",
    "passport-oauth1": "^1.1.0",
    "pg": "^7.4.3",
    "pg-hstore": "^2.3.2",
    "pug": "~2.0.0-beta11",
    "react": "^16.4.0",
    "react-dom": "^16.4.0",
    "react-dropzone": "^4.2.11",
    "react-slick": "^0.23.1",
    "semantic-ui-react": "^0.81.1",
    "sequelize": "^4.37.10",
    "serve-favicon": "~2.4.2",
    "shortid": "^2.2.8",
    "supertest": "^3.1.0",
    "xtraverse": "^0.1.0"
  },
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-loader": "^7.1.4",
    "babel-polyfill": "^6.26.0",
    "babel-preset-env": "^1.7.0",
    "babel-preset-react": "^6.24.1",
    "semantic-ui": "^2.3.1",
    "webpack": "^4.9.1",
    "webpack-cli": "^2.1.4"
  },
  "engines": {
    "node": "8.9.1"
  }
}

メジャーなものしか使っていません。どれも公式ドキュメントが親切です。その他解説記事などもたくさん参考にさせていただきました。
余談ですがこういった WEB に情報をまとめてくださる方や技術ブログを書いてくださる方のおかげで、墓穴を掘らずにすんだことが多々ありました。
本当にありがとうございます。感謝!
おそらく参考にしたものを全てというわけにはいきませんが、探せるものは参考にまとめさせていただきました。

最後に

ここはいわゆるポエムです。
「本当はうだうだ言ってないで、これと決めた構成で個人の WEB サービスなんて作ればいいはず。そんなことよりもサービスを詰める時間に回した方がいい。むしろ枯れてる技術の方が安定してて良いだろう」そう思うこともあります。その一方、常に新しい技術を追い求めべきという考えもあります。なぜなら新しい技術というのは過去の技術課題の克服のために作られており、そのメリットを享受できる可能性があるからです。また、こういったもっともらしい理由以外にも車輪を喜んで再発明しちゃう人、新しいことを追い求める人がエンジニアとしては一流になるのだろうなという勝手な仮説が僕の中にあります。だから新しいものや、新しく再発明することも目指すべきと考えてしまうんですね。

 当然ながら最終的にはこれらを競合させて、トレードオフでやっていくしかない。

 ひとまずは学生のうちに途中で投げ出さずにプロダクトをひとつリリースできたことについて自分を褒めました。正直満足いかないことだらけですが、これからもプロダクトをたくさん作っていきたいと思います。

以上
最後まで読んでくださりありがとうございました。

参考