かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

JavaScript Express async function の UnhandledPromiseRejectionWarning にハマる。

Express で簡単な JWT の API を作って実験していた際に middleware を async function に変更したら UnhandledPromiseRejectionWarning が出るようになってハマってしまったのでメモ

UnhandledPromiseRejectionWarning になったコード

// router
const router = require('express').Router();
const verify = require('./verify');

router.get('/', verify, (req, res) => {
  res.send(req.data);
});

module.exports = router;

middleware

// verify.js
const jwt = require('jsonwebtoken');
const { checkTokenValidity } = require('./token');

module.exports = async function(req, res, next) {
  const token = req.header(TOKEN_HEADER);
  if (!token) {
    return res.status(401).send('Access Denied');
  }

  try {
    // 有効期限切れなどで verify ができないと catch に処理が流れる
    const verified = jwt.verify(token, TOKEN_SECRET);
    
    // token を redis に問い合わせて revoke されてないかチェックする
    const isValidity = await checkTokenValidity(token);
    if (!isValidity) {
      throw new Error('Token is revoked');
    }
    
    req.data = verified;
    next();
  } catch(err) {
    res.status(400).send('Invalid Token');
  }
}

token が渡されない場合や、token が有効な時は問題がないのですが、token が有効でなく Invalid Token になる場合に UnhandledPromiseRejectionWarning が発生します。

note. UnhandledPromiseRejectionWarning

UnhandledPromiseRejectionWarning は非同期処理の Promise でエラーが catch できなかった時に発生するエラー

非同期関数の await checkTokenValidity(token) を追加した後から、この warning が発生するようになったので、この関数が悪いのかと思い別途 try 〜 catch で囲ってみたり、コメントアウトしてみたり、Promise.reject() を書いてみたりしても変化がなく、どうやら middleware を async function にした事で warning が発生するようになっていました。

HEADER が 2重に返されていたのが原因だった

長い warning をよく読んでみると ERR_HTTP_HEADERS_SENT という記述があり、どうやら res.send() された後に更に res.send() がされているのがこの warning 発生の直接的な原因となっているようでした。

async function 内では明示的に return する必要がある

middleware が async function でない場合は next() が呼ばれず res.send() してしまえば、その場で処理が終了になっていたものが、async function にしたことで返される値を待つようになってしまったので、res.send() で処理が終了にならず、関数の最後に返される undefinedPromise.resolve() として受け取っていたということでした。

// verify.js
module.exports = async function(req, res, next) {
  // ...
  try {
    // …
  } catch(err) {
    res.status(400).send('Invalid Token');
  }
  // ここで即時 undefined が return される
}

// router
router.get('/', verify, (req, res) => {
  // verify が async function なので、return を待つのでエラーの場合
  // 関数の最後で返される undefined を Promise.resolve として次の res.send() が呼ばれる
  res.send(req.data);
});

async function な middleware で res.send する時は明示的に return をする

明示的に return res.send() をすればOK

// verify.js
module.exports = async function(req, res, next) {
  // ...
  try {
    // …
  } catch(err) {
    // エラーの場合はこれが返されて関数は終了
    return res.status(400).send('Invalid Token');
  }
  // ここに到達することはない
}

// router
router.get('/', verify, (req, res) => {
  // エラーの場合先に res.send() が返されて実行されているので、res.send(req.data) は実行されない
  res.send(req.data);
});

 
単純に async / await の挙動の考慮漏れが原因でした…
小一時間ほどハマってしまった。


[参考]

アイドルタイムプリパラ☆ミュージックコレクション

アイドルタイムプリパラ☆ミュージックコレクション

  • アーティスト:V.A.
  • 発売日: 2018/06/27
  • メディア: CD

プリパラの履修始めました。

Docker PHP composer を使えるようにしたい。

旋回までのあらすじ

Docker で作った PHP 環境で composer を使おうとしたら入ってなかったので使えるようにしたメモ

docker-compose.yml

version: '3'
services:
  api:
    build:
      context: ./php
      dockerfile: Dockerfile
    volumes:
      - ./api:/var/www
    depends_on:
      - db

Dockerfile に compose のイメージを取ってくる指定を追加する

(optimal) create your own build image and install Composer inside it.
Note: Docker 17.05 introduced multi-stage builds, simplifying this enormously:

COPY --from=composer /usr/bin/composer /usr/bin/composer
cf. https://hub.docker.com/_/composer:

./php/Dockerfile

FROM php:7.4-fpm
RUN apt-get update
RUN apt-get install -y vim
RUN docker-php-ext-install pdo_mysql

COPY php.ini /usr/local/etc/php/
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

composer:latest の指定で最新版の composer イメージ使用する
※ docker が v17.05 以上である必要がある

docker-compose up --build で再ビルドして composer のコマンドが使えればOK

$ docker-compose exec api composer --version
Composer version 1.10.7 2020-06-03 10:03:56

api の部分は docker-compose.yml で指定したコンテナ名に適時変更してください  
導入簡単だった。
<完>


[参考]

Docker nginx + PHP (PDO) + MySQL + frontend な SPA 開発環境作った

インフラ知識皆無なので Docker は雰囲気で使っています。

構成

/
|- docker-compose.yml
|- /api
|- /frontend
|    |- Dockerfile
|- /mysql
|    |- /data
|    |- /init
|    |    |- 1_ddl.sql
|    |- Dockerfile
|    |- my.cnf
|- /nginx
|    |- nginx.conf
|- /php
     |- Dockerfile
     |- php.ini

docker-compose.yml

version: '3'
services:
  nginx:
    image: nginx:latest
    ports:
      - 3000:3000
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
      - ./api:/var/www/html
    depends_on:
      - api

  api:
    build:
      context: ./php
      dockerfile: Dockerfile
    volumes:
      - ./api:/var/www/html
    depends_on:
      - db

  db:
    build:
      context: ./mysql
      dockerfile: Dockerfile
    command: mysqld --character-set-server=utf8 --collation-server=utf8_general_ci
    ports:
      - 13306:3306
    volumes:
      - ./mysql/data:/var/lib/mysql
      - ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf
      - ./mysql/init:/docker-entrypoint-initdb.d
    environment:
      MYSQL_ROOT_PASSWORD: root

  phpmyadmin:
    image: phpmyadmin/phpmyadmin:latest
    ports:
      - 8888:80
    depends_on:
      - db

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    volumes:
      - ./frontend:/app
      - ./frontend/yarn.lock:/app/yarn.lock
      - ./frontend/node_modules:/app/node_modules
    ports:
      - 8000:3000
    depends_on:
      - api
    tty: true
    working_dir: "/app"
    command: bash -c "yarn start"

nginx

services:
  nginx:
    image: nginx:latest
    ports:
      - 3000:3000
    volumes:
      # 設定ファイルを nginx の /etc/nginx/conf.d/default.conf にマウント
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
      # ./api  を /var/www/html にマウント
      - ./api:/var/www/html
    depends_on:
      - api

nginx は PHPAPI として動かすので Host Port : Contaner Port3000:3000 に設定。コンテナ内から port 3000 でアクセスできる

./nginx/nginx.conf

server {
    listen 3000;
    server_name localhost;

    root  /var/www/html;
    index index.php index.html;

    access_log /var/log/nginx/access.log;
    error_log  /var/log/nginx/error.log;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        fastcgi_pass api:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME  $document_root$fastcgi_script_name;
        include       fastcgi_params;
    }
}

listen するポートを docker-compose.yml のポートと合わせる


PHP (api)

services:
  api:
    build:
      context: ./php
      dockerfile: Dockerfile
    volumes:
      - ./api:/var/www/html
    depends_on:
      - db

./php ディレクトリにある Dockerfile を元に php をbuildする

./php/Dockerfile

FROM php:7.4-fpm
# PDO を使えるようにする設定
RUN apt-get update
RUN apt-get install -y vim
RUN docker-php-ext-install pdo_mysql
# php.ini をコンテナにコピー
COPY php.ini /usr/local/etc/php/

./php/php.ini

date.timezone = "Asia/Tokyo"

timezone だけ設定


MySQL

services:
  db:
    build:
      context: ./mysql
      dockerfile: Dockerfile
    # 作成する database のデフォルトの文字エンコードを指定
    command: mysqld --character-set-server=utf8 --collation-server=utf8_general_ci
    ports:
      - 13306:3306
    volumes:
      # 実際のデータを ./mysql/data に入れる
      - ./mysql/data:/var/lib/mysql
      # 設定ファイルの指定
      - ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf
      # 初期データを作成するための設定
      - ./mysql/init:/docker-entrypoint-initdb.d
    environment:
      # mysql root ユーザーのパスワード
      MYSQL_ROOT_PASSWORD: root

  # phpmyadmin  
  phpmyadmin:
    image: phpmyadmin/phpmyadmin:latest
    ports:
      - 8888:80
    depends_on:
      - db

./mysql/Dockerfile

FROM mysql:5.7

# init ディレクトリ内のファイルを初期化時に実行させる
COPY init/* /docker-entrypoint-initdb.d/

CMD ["mysqld"]

docker-compose.ymlvolumes にある - ./mysql/init:/docker-entrypoint-initdb.d との違いが判ってないい

./mysql/my.cnf

エンコードの指定をしておく

[mysqld]
character-set-server=utf8
collation-server=utf8_general_ci

[client]
default-character-set=utf8

docker-compose.ymlcommand: mysqld --character-set-server=utf8 --collation-server=utf8_general_ci と設定が被ってる気がするけど、片方だけでよいのか判ってない

初期データを作成する

docker の MySQL image では /docker-entrypoint-initdb.d というディレクトリ内にあるスクリプトを昇順で実行する仕組みがあるようなので、テーブルの作成やデータの流し込みするファイルを置いておくことで DB の初期化ができる

./mysql/init/docker-entrypoint-initdb.d にマウントしているので、./mysql/init 内にスクリプトファイルを作成する

e.g. ./mysql/init/1_ddl.sql database / table が無ければ作成する
CREATE DATABASE IF NOT EXISTS samle_db;

CREATE TABLE IF NOT EXISTS samle_db.table(...);

MySQL の設定が反映されない場合

./mysql/data 内にファイルが残っている状態だと image を再ビルドしても反映されない場合があるので、MySQL の設定を変更する際は ./mysql/data を削除してから build するのがよい。


Frontend

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    volumes:
      - ./frontend:/app
      - ./frontend/yarn.lock:/app/yarn.lock
      - ./frontend/node_modules:/app/node_modules
    ports:
      # api と通信するので Contaner Port を 3000 に指定
      - 8000:3000
    depends_on:
      - api
    tty: true
    working_dir: "/app"
    command: bash -c "yarn start"

create-react-app で作成する場合は、作業ディレクトリ内で次のコマンドを作成

$ npx create-react-app frontend

./frontend ディレクトリに react アプリが作成されるので、Dockerfile を作成する

$ touch frontend/Dockerfile

Dockerfile

FROM node:12.18.0

WORKDIR /app

build する

$ docker-compose up -d

問題がなければ localhost:8000 で react が動作しており、localhost:3000PHP が動作している状態になっている筈です。

所感

参考サイトを見ながら見様見真似で docker-compose や Docker ファイルを作成しましたが、なんとか動いているSPA開発環境を作成することができました。
この設定だとフロントのアプリも docker 内で動いているのですが Mac だと IO が遅くてビルドが遅いので、実は docker 内に作成せずに yarn run start とかで動作させて axios で 3000 ポートを指定してリクエスト投げれば問題ないのでは?と思い始めているので、フロントを docker から下ろす方法を模索してみたいと思います。

設定については理解が浅いので、少しつづキャッチアップしていきたいです。


[参考]