React by Examples 第4回: Express + Sequelize による APIサーバ

今回から, Express で, バックエンドを作ります。Object Relational Mapping (ORM) としては Sequelize を使います。

[2021-03] Express v4 は Node.js のための web framework で非常にメジャーだが、機能がショボく、にもかかわらず遅い。次世代と目されているのは, Express.js 作者が開発している後継プロジェクト, Koa v2; koajs/koa: Expressive middleware for node.js using ES2017 async functions

あと有力なのは、JavaScript の割には爆速フレームワーク Fastify; Fastify, Fast and low overhead web framework, for Node.js (他の言語のそれに比べれば断然遅いことに注意。)

ほかには Restify, Hapi もあるが、上記二つだけ調べればよい。

ORM についても, Sequelize は冗長で、生産性が低い。TypeScript 前提だと TypeORM, Prisma があるが、plain JavaScript でよさそうなのは Bookshelf.js. Knex SQLクエリビルダがよい。

[/ 2021-03 ここまで]

URL の設計

基本的な REST API を作る。URL と API との対応については best practice がある。それに合わせるのがよい。

フロントエンドで処理が完結するアクションと、バックエンドにメッセージを投げるものがある。例えば、写真コレクションに関する URL は次のようになる。

HTTP method パス コントローラ#アクション 目的
GET /photos サーバ: photos#index すべての写真の一覧を表示
GET /photos/new フロントエンドで処理 写真を1つ作成するためのHTMLフォームを表示
POST /photos サーバ: photos#create 写真を1つ作成する. 201 Created
GET /photos/:id photos#show 特定の写真を表示する 200 OK. 見つからないとき 404 Not Found.
GET /photos/:id/edit フロントエンドで処理 写真編集用のHTMLフォームを表示
PUT /photos/:id サーバ: photos#update 特定の写真を更新する. 見つからないとき 404 Not Found. 更新に失敗 405 Method Not Allowed.
DELETE /photos/:id サーバ: photos#destroy 特定の写真を削除する. 見つからないとき 404 Not Found. 更新に失敗 405 Method Not Allowed.

HTTP method として PUT の代わりに PATCH を使うこともある。PATCH method は RFC 5789 (2010年3月) で定められているが, 改訂 HTTP/1.1 (RFC 7231; 2014年6月) には取り込まれなかった。傍流と思う。

フロントエンドの更新

内容は前回見たので、今回は, フロントエンドからバックエンドを呼び出す部分。

簡単な API クラスを作る。

fetch()

バックエンドを呼び出すのも HTTP プロトコルを使う (Ajax)。XMLHttpRequest は廃れた。現代では fetch() を使う。IDL宣言は次のとおり;

Promise<Response> fetch(RequestInfo input, optional RequestInit init = {});

2引数として input に文字列による URL, init にオプションを与えて呼び出すか, 1引数で使って, Request オブジェクトを与えるか, のいずれか。どちらも同じことができるが、前者のほうが書きやすい。

CORS (Cross-Origin Resource Sharing) プロトコル

フロントエンドのプログラム (JavaScript) を配布したサーバとバックエンドのサーバとで, ホスト名が違うかあるいはポート番号が違うと, cross-origin になる。Webブラウザ上で動く JavaScript からバックエンドに対して直接リクエストを発出する際, cross-origin 扱いになると、制限が多い。

Cross-origin では、原則として、HTML form要素で送信可能な内容を超えるリソースの共有が禁止される。明示的に許可する仕組みが CORS プロトコルだ。

fetch() の第2引数に与える mode: は, "navigate", "same-origin", "no-cors", "cors" または "websocket" のいずれかを指定できます。デフォルト値は "no-cors" です。

HTTP method = PUTcontent-type ヘッダを使う場合、CORS が必要になる。

Cross-origin であることを宣言する mode:"cors" としたうえで, サーバ側で Access-Control-Allow-Origin, Access-Control-Allow-Headers ヘッダなど追加対策です。

しかし、バックエンドから見ると、フロントエンドから来るリクエストはいくらでも改竄できるので、そもそも信用してはなりません。そうすると、バックエンドから見る限り、cross-origin 対策は重要ではありません。

したがい、簡単のため、次のようにしてもいいでしょう。

  • 開発環境では, フロントエンドの package.json ファイルに次のように, バックエンドの URLを書く
    "proxy": "http://localhost:3001"
    これで、フロントエンドのサーバは、クライアントからのリクエストをバックエンドに転送します。
  • 本番環境では, Webサーバの reverse proxy 設定で, 同様にする。

Webブラウザから見れば、フロントエンドとバックエンドが same-origin に見えるようになります。

これらのことを踏まえて, バックエンドを呼び出す API クラス, apicall 関数を書きます。

JavaScript
[RAW]
  1. const ENDPOINT = 'http://localhost:3000/api';
  2. class API
  3. {
  4. // static method のなかで this は レシーバ (=APIの子クラス) になる。
  5. // これで、どのクラスか判別できる。
  6. // async function は Promise を返す. 例外も Promise に変換される.
  7. static async apicall(method, url, body) {
  8. // 現代では, XMLHttpRequest は古く、使われない.
  9. // fetch() の戻り値は Promise<Response>
  10. // network error は例外. それ以外は .then() で受ける.
  11. return fetch(`${ENDPOINT}${url}`, {
  12. method: method,
  13. body: JSON.stringify(body),
  14. headers: {
  15. // application/json のやりとりは CORS 対応が必要.
  16. 'content-type': 'application/json',
  17. accept: 'application/json' },
  18. }).then( res => {
  19. if ( res.status >= 200 && res.status <= 299 )
  20. return res.json();
  21. else
  22. throw new Error(`${res.status} ${res.statusText}`);
  23. });
  24. }

JavaScript は、明示的に return で戻さないと関数の戻り値が undefined になります。最後に評価した式の値ではない。Promise は, メソッドチェインで書き連ねることが多いですが、これと相性が悪い。

行20 の return で戻している値は, 行28 - 33 の .then() が返す Promise です。念のため, .then() に渡しているハンドラ関数の戻り値ではない。

基本的な REST API の呼出しを作ります。

JavaScript
[RAW]
  1. static async find_all(filter) {
  2. const model_name = this.name.toLowerCase() + "s";
  3. return API.apicall('GET', "/" + model_name);
  4. }
  5. static async find(filter) {
  6. const id = Number(filter); // 現状, id指定のみ.
  7. if ( !(id > 0) )
  8. throw new RangeError(":id must be aNumber");
  9. const model_name = this.name.toLowerCase() + "s";
  10. const response = await API.apicall('GET',
  11. "/" + model_name + `/${id}`);
  12. console.log(response); // DEBUG
  13. if (response.error)
  14. throw new Error(response.error.message);
  15. return response;
  16. }
  17. // item.id id
  18. static async create(item) {
  19. const model_name = this.name.toLowerCase() + "s";
  20. const id = Number(item.id);
  21. let response;
  22. if ( id > 0 ) { // update
  23. response = await API.apicall('PUT',
  24. "/" + model_name + `/${id}`, item);
  25. }
  26. else {
  27. response = await API.apicall('POST', "/" + model_name, item);
  28. }
  29. if (response.error)
  30. throw new Error(response.error.message);
  31. console.log(response); // DEBUG
  32. return response;
  33. }
  34. static async delete(filter) {
  35. const model_name = this.name.toLowerCase() + "s";
  36. const id = Number(filter);
  37. if ( !(id > 0) )
  38. throw new RangeError(":id must be aNumber");
  39. return API.apicall('DELETE', "/" + model_name + `/${id}`);
  40. }

this.name でクラス名が得られるので, それで URL を作ります。

とりあえず, create()で, CREATE と UPDATE を兼ねるようにしてみました。

React でどこで非同期呼出しするか

React component のライフサイクルに注目。普通に公式の図が分かりやすい。

https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

コンストラクタ, render(), componentDidMount(), componentDidUpdate(), componentWillUnmount() がある。副作用を持ち、DOM を更新するには, componentDidMount() メソッドに書けばよい。

コンストラクタでは isLoaded, error を用意しておく。

JavaScript
[RAW]
  1. class ArticleShow extends React.Component
  2. {
  3. constructor(props) {
  4. super(props);
  5. this.state = {
  6. isLoaded: false,
  7. error: null,
  8. redirectToList: false,
  9. post:null };
  10. // URLが不正?
  11. let { id } = props.match.params;
  12. if ( !(Number(id) > 0) ) {
  13. this.state['error'] = {
  14. 'code': 400,
  15. 'message': 'parameter "id" is invalid' };
  16. }
  17. }
  18. componentDidMount() {
  19. if ( this.state.error )
  20. return;
  21. let { id } = this.props.match.params;
  22. Article.find(id).then( post => {
  23. this.setState({
  24. isLoaded: true,
  25. error: null,
  26. post: post });
  27. })
  28. .catch( err => {
  29. this.setState( {error: err} );
  30. });
  31. }

バックエンド

新しく作ります。Express を使います。

Express は, minimalist のための Webアプリケーションフレームワークです。Node.js 上で動きます。まんま Ruby 用 Sinatra の JavaScript 版です。

Express は v3まで, Sencha Labs Connect 上に実装されていましたが、Express 4 (現行バージョン) から Connect に依存しなくなりました。

ミドルウェアを積み重ねて、基本的な機能を提供します。CSRF protection のための csurf, セッション管理のための express-session など。

[2021-03] Express は JavaScript でバックエンドを作る場合の de facto standard です。しかし、前述のとおり、新しくプログラムを作るなら Koa v2 や Fastify のほうがよさそう。

Sequelize は, Node.js で動く, 多くのRDBMSサーバをサポートした ORM です。DBマイグレーションもできます。まんま ActiveRecord (Ruby on Rails) です。

Promise ベースであることも売りにしていますが、この点ははっきり失敗です。同期処理するものを Promise ベースにしてどうする。関連して, 挙動が微妙に安定していません。ドキュメントも不十分です。

2020年5月現在, バックエンドとしては, Ruby on Rails の API モードを使った方がはるかにまともでしょう。フロントエンドと無理に開発言語を合わせる必要はありません。

あるいは, TypeScript 前提であれば, TypeORM がよかった、か。

開始

$ mkdir myapp
$ cd myapp
$ npm init

対話的な質問に回答します。Entry point は src/app.js 辺りがよく使われると思います。package.json ファイルができます。

必要なパッケージを導入します。

$ npm install express sequelize sqlite3 

package.json ファイルを修正します。--unhandled-rejections=strict オプション付きで走らせます。

    "scripts": {
        "start": "node --unhandled-rejections=strict ./src/app.js",

エントリポイント

エントリポイントにした src/app.js ファイルです。基本的な形は、どのアプリケーションでも大体同じになります。

JavaScript
[RAW]
  1. const express = require('express');
  2. // ExpressJS v4.16.0 で, body-parser相当の機能が組み込まれた。
  3. //const bodyparser = require('body-parser');
  4. const articles = require('./articles');
  5. // Express application
  6. const app = express();
  7. // middleware
  8. //app.use(bodyparser.bodyParser.json());
  9. app.use(express.json());
  10. // routing
  11. app.use('/api/articles', articles);
  12. const PORT = process.env.PORT || 3001;
  13. // backend は, localhost に限定しなければならない
  14. app.listen(PORT, 'localhost', function() {
  15. console.log("Example app listening on port %d in '%s' mode",
  16. PORT, app.settings.env);
  17. });

まず require('express') します。Express app オブジェクトを生成し、必要なミドルウェアを use() で組み込みます。

URLパスと転送先オブジェクトとで routing します。

そして, listen() で待ち受けます。

ルーティング

Express の基本は, URLパスとハンドラとの組み合わせです。

JavaScript
[RAW]
  1. const express = require('express');
  2. const models = require('./models');
  3. const router = express.Router();
  4. const ResourceUrl = '/:id';
  5. // List
  6. router.get('/', async function(request, response) {
  7. const articles = await models.Article.findAll();
  8. response.json(articles);
  9. });
  10. // Show an item
  11. router.get(ResourceUrl, async function(request, response) {
  12. const result = await models.Article.findOne({
  13. where: {id:request.params.id}
  14. });
  15. if (!result) {
  16. // send(body, status) is deprecated.
  17. response.status(404).json({
  18. "error": {
  19. "code": "404",
  20. "message": "Record not found",
  21. "target": "query" }
  22. });
  23. return;
  24. }
  25. response.json(result);
  26. });

express.Router() でルータオブジェクトを得ます。

get(), post(), put(), delete() がそれぞれ HTTPメソッドに対応します。第1引数がURLパス, 第2引数がハンドラ関数です。

URLパスは :id のように値を取り出してハンドラ関数で使うことができます。request.params のプロパティになります。

データベース

設定

.sequelizerc ファイルが出発点。中身はただの JavaScript。設定ファイルの場所を指定する。

JavaScript
[RAW]
  1. const path = require('path');
  2. module.exports = {
  3. 'config': path.resolve('config', 'database.js'),
  4. 'models-path': path.resolve('src', 'models'),
  5. //'seeders-path': path.resolve('.', 'seeders'),
  6. //'migrations-path': path.resolve('.', 'migrations')
  7. };

config/database.js ファイル。データベースの接続情報を書く。ただの JavaScript なので、パスワードは環境変数で得るか, 別ファイルにして gitリポジトリに保存しないようにする。

利用時に, 環境変数 NODE_ENV で振り分けられるようになっている。次の例は、DBMS を替えるように書いているが、通常ではない。

JavaScript
[RAW]
  1. module.exports = {
  2. "development": {
  3. "dialect": "sqlite",
  4. "storage": "./db.dev.sqlite"
  5. },
  6. "test": {
  7. "username": "root",
  8. "password": null,
  9. "database": "database_test",
  10. "host": "127.0.0.1",
  11. "dialect": "mysql",
  12. "operatorsAliases": false
  13. },
  14. "production": {
  15. "username": "root",
  16. "password": null,
  17. "database": "database_production",
  18. "host": "127.0.0.1",
  19. "dialect": "mysql",
  20. "operatorsAliases": false
  21. }
  22. };

テーブル定義

Sequelize は, データベーススキーマをプログラムで変更していくことができる。正直、この機能がない ORM は使ってられない。

雛形は npx sequelize-cli model:generate コマンドで作る。

$ npx sequelize-cli model:generate --name Hoge --attributes title:string,body:string,ref:integer

Sequelize CLI [Node: 12.16.3, CLI: 5.5.1, ORM: 5.21.13]

New model was created at /home/hori/repos/react-by-examples/04_crud-with-nodejs/nodejs-backend/src/models/hoge.js .
New migration was created at /home/hori/repos/react-by-examples/04_crud-with-nodejs/nodejs-backend/migrations/20200614094703-Hoge.js .
--name オプション
モデル名。単数形で与える
--attributes
カラムのリスト。カラム名:型名.

migrations/ にマイグレーションスクリプトが, src/models/ にモデルの定義が生成される。

テーブル名は複数形になる。この単数形/複数形、大文字始まり/小文字始まりがものすごく問題を難しくする。間違えるとエラーにならず、不思議な挙動になる。エラーになってくれればまだしもだが、大変。

マイグレーション実行は次のコマンド;

$ npx sequelize-cli db:migrate

クエリ

とにかく、常に await を付けること。付け忘れると上手く動かない。同期処理の関数宣言にすればよかったのに、どうしてこうなった。

全部得る

JavaScript
[RAW]
  1. const articles = await models.Article.findAll();

一つ得る. 見つからなかった時は null.

JavaScript
[RAW]
  1. const result = await models.Article.findOne({
  2. where: {id:request.params.id}
  3. });

新しくレコードを作る. 整合性検査が走る。try で囲むこと。

JavaScript
[RAW]
  1. try {
  2. // create() = build() + save().
  3. article = await models.Article.create(request.body);
  4. }
  5. catch (err) {
  6. // validation error

更新. 探してから、save(). await を忘れずに。

JavaScript
[RAW]
  1. var article = await models.Article.findOne({
  2. where: {id:request.params.id}
  3. });
  4. if (!article) {
  5. 見つからなかったとき
  6. return;
  7. }
  8. try {
  9. article.title = request.body.title;
  10. article.body = request.body.body;
  11. await article.save();
  12. }
  13. catch (err) {

削除. 探してから destroy().

JavaScript
[RAW]
  1. try {
  2. await article.destroy();
  3. }
  4. catch (err) {