ミルク色の記録

やったこと、やってみたこと

Raspbianの初期起動でのrootfsパーティション自動拡張の仕組みを調べた

環境

試したRaspbianのイメージは2018-11-13-raspbian-stretch-lite.img。母艦はLinux PCで調べた。

きっかけ

Raspbian Jessieのいつ頃からか忘れたけど、イメージをSDカードに焼いてRaspberry PIに入れて起動すると、 自動でrootfsのパーティションがSDカードの容量全体まで拡張されるようになった。

RaspbianのOSイメージをSDカードに焼いた直後のパーティションはfdiskで確認すると、次のようになっている。

$ sudo fdisk -l /dev/mmcblk0
Disk /dev/mmcblk0: 3.7 GiB, 3965190144 bytes, 7744512 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7ee80803

デバイス       起動 Start 最後から セクタ  Size Id タイプ
/dev/mmcblk0p1       8192    98045   89854 43.9M  c W95 FAT32 (LBA)
/dev/mmcblk0p2      98304  3645439 3547136  1.7G 83 Linux

これをRaspberry PIに入れて起動すると、自動拡張のメッセージが表示されて、再起動後に次のようになる。

$ sudo fdisk -l /dev/mmcblk0
Disk /dev/mmcblk0: 3.7 GiB, 3965190144 bytes, 7744512 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7273b820

デバイス       起動 Start 最後から セクタ  Size Id タイプ
/dev/mmcblk0p1       8192    98045   89854 43.9M  c W95 FAT32 (LBA)
/dev/mmcblk0p2      98304  7744511 7646208  3.7G 83 Linux

/dev/mmcblk0p2の容量が1.7Gから3.7Gに広がっているのがわかる。

これはこれで便利なのだけれど、rootfsとは別のパーティション作ったり色々やりたいことがあるので、 そもそもこれはどうやって実現しているのだろうと思って調べてみた。

/boot/cmdline.txtを調べる

ブート時になにかやっているのだから/boot/cmdline.txtをとりあえず見てみようと思い、 イメージをSDカードに焼いてLinux PCからマウントしてファイルを覗いてみた。

/boot/cmdline.txtの中身

dwc_otg.lpm_enable=0 console=serial0,115200 console=tty1 root=PARTUUID=7ee80803-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait quiet init=/usr/lib/raspi-config/init_resize.sh

末尾に、なんかそのものズバリになりそうなinit=/usr/lib/raspi-config/init_resize.shを発見。

/usr/lib/raspi-config/init_resize.shを調べる

今度はrootfsをマウントして/usr/lib/raspi-config/init_resize.shの中身を覗いてみた。

init_resize.shの中身は次の通り。

#!/bin/sh

reboot_pi () {
  umount /boot
  mount / -o remount,ro
  sync
  if [ "$NOOBS" = "1" ]; then
    if [ "$NEW_KERNEL" = "1" ]; then
      reboot -f "$BOOT_PART_NUM"
    else
      echo "$BOOT_PART_NUM" > "/sys/module/${BCM_MODULE}/parameters/reboot_part"
    fi
  fi
  echo b > /proc/sysrq-trigger
  sleep 5
  exit 0
}

check_commands () {
  if ! command -v whiptail > /dev/null; then
      echo "whiptail not found"
      sleep 5
      return 1
  fi
  for COMMAND in grep cut sed parted fdisk findmnt partprobe; do
    if ! command -v $COMMAND > /dev/null; then
      FAIL_REASON="$COMMAND not found"
      return 1
    fi
  done
  return 0
}

check_noobs () {
  if [ "$BOOT_PART_NUM" = "1" ]; then
    NOOBS=0
  else
    NOOBS=1
  fi
}

get_variables () {
  ROOT_PART_DEV=$(findmnt / -o source -n)
  ROOT_PART_NAME=$(echo "$ROOT_PART_DEV" | cut -d "/" -f 3)
  ROOT_DEV_NAME=$(echo /sys/block/*/"${ROOT_PART_NAME}" | cut -d "/" -f 4)
  ROOT_DEV="/dev/${ROOT_DEV_NAME}"
  ROOT_PART_NUM=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/partition")

  BOOT_PART_DEV=$(findmnt /boot -o source -n)
  BOOT_PART_NAME=$(echo "$BOOT_PART_DEV" | cut -d "/" -f 3)
  BOOT_DEV_NAME=$(echo /sys/block/*/"${BOOT_PART_NAME}" | cut -d "/" -f 4)
  BOOT_PART_NUM=$(cat "/sys/block/${BOOT_DEV_NAME}/${BOOT_PART_NAME}/partition")

  OLD_DISKID=$(fdisk -l "$ROOT_DEV" | sed -n 's/Disk identifier: 0x\([^ ]*\)/\1/p')

  check_noobs

  ROOT_DEV_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/size")
  TARGET_END=$((ROOT_DEV_SIZE - 1))

  PARTITION_TABLE=$(parted -m "$ROOT_DEV" unit s print | tr -d 's')

  LAST_PART_NUM=$(echo "$PARTITION_TABLE" | tail -n 1 | cut -d ":" -f 1)

  ROOT_PART_LINE=$(echo "$PARTITION_TABLE" | grep -e "^${ROOT_PART_NUM}:")
  ROOT_PART_START=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 2)
  ROOT_PART_END=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 3)

  if [ "$NOOBS" = "1" ]; then
    EXT_PART_LINE=$(echo "$PARTITION_TABLE" | grep ":::;" | head -n 1)
    EXT_PART_NUM=$(echo "$EXT_PART_LINE" | cut -d ":" -f 1)
    EXT_PART_START=$(echo "$EXT_PART_LINE" | cut -d ":" -f 2)
    EXT_PART_END=$(echo "$EXT_PART_LINE" | cut -d ":" -f 3)
  fi
}

fix_partuuid() {
  DISKID="$(fdisk -l "$ROOT_DEV" | sed -n 's/Disk identifier: 0x\([^ ]*\)/\1/p')"

  sed -i "s/${OLD_DISKID}/${DISKID}/g" /etc/fstab
  sed -i "s/${OLD_DISKID}/${DISKID}/" /boot/cmdline.txt
}

check_variables () {
  if [ "$NOOBS" = "1" ]; then
    if [ "$EXT_PART_NUM" -gt 4 ] || \
       [ "$EXT_PART_START" -gt "$ROOT_PART_START" ] || \
       [ "$EXT_PART_END" -lt "$ROOT_PART_END" ]; then
      FAIL_REASON="Unsupported extended partition"
      return 1
    fi
  fi

  if [ "$BOOT_DEV_NAME" != "$ROOT_DEV_NAME" ]; then
      FAIL_REASON="Boot and root partitions are on different devices"
      return 1
  fi

  if [ "$ROOT_PART_NUM" -ne "$LAST_PART_NUM" ]; then
    FAIL_REASON="Root partition should be last partition"
    return 1
  fi

  if [ "$ROOT_PART_END" -gt "$TARGET_END" ]; then
    FAIL_REASON="Root partition runs past the end of device"
    return 1
  fi

  if [ ! -b "$ROOT_DEV" ] || [ ! -b "$ROOT_PART_DEV" ] || [ ! -b "$BOOT_PART_DEV" ] ; then
    FAIL_REASON="Could not determine partitions"
    return 1
  fi
}

check_kernel () {
  local MAJOR=$(uname -r | cut -f1 -d.)
  local MINOR=$(uname -r | cut -f2 -d.)
  if [ "$MAJOR" -eq "4" ] && [ "$MINOR" -lt "9" ]; then
    return 0
  fi
  if [ "$MAJOR" -lt "4" ]; then
    return 0
  fi
  NEW_KERNEL=1
}

main () {
  get_variables

  if ! check_variables; then
    return 1
  fi

  check_kernel

  if [ "$NOOBS" = "1" ] && [ "$NEW_KERNEL" != "1" ]; then
    BCM_MODULE=$(grep -e "^Hardware" /proc/cpuinfo | cut -d ":" -f 2 | tr -d " " | tr '[:upper:]' '[:lower:]')
    if ! modprobe "$BCM_MODULE"; then
      FAIL_REASON="Couldn't load BCM module $BCM_MODULE"
      return 1
    fi
  fi

  if [ "$ROOT_PART_END" -eq "$TARGET_END" ]; then
    reboot_pi
  fi

  if [ "$NOOBS" = "1" ]; then
    if ! parted -m "$ROOT_DEV" u s resizepart "$EXT_PART_NUM" yes "$TARGET_END"; then
      FAIL_REASON="Extended partition resize failed"
      return 1
    fi
  fi

  if ! parted -m "$ROOT_DEV" u s resizepart "$ROOT_PART_NUM" "$TARGET_END"; then
    FAIL_REASON="Root partition resize failed"
    return 1
  fi

  partprobe "$ROOT_DEV"
  fix_partuuid

  return 0
}

mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t tmpfs tmp /run
mkdir -p /run/systemd

mount /boot
mount / -o remount,rw

sed -i 's| init=/usr/lib/raspi-config/init_resize.sh||' /boot/cmdline.txt
if ! grep -q splash /boot/cmdline.txt; then
  sed -i "s/ quiet//g" /boot/cmdline.txt
fi
sync

echo 1 > /proc/sys/kernel/sysrq

if ! check_commands; then
  reboot_pi
fi

if main; then
  whiptail --infobox "Resized root filesystem. Rebooting in 5 seconds..." 20 60
  sleep 5
else
  sleep 5
  whiptail --msgbox "Could not expand filesystem, please try raspi-config or rc_gui.\n${FAIL_REASON}" 20 60
fi

reboot_pi

partedを使って$ROOT_DEVをゴニョゴニョしている部分があるのでビンゴっぽい。

whiptail --infobox "Resized root filesystem. Rebooting in 5 seconds..." 20 60ってのがリサイズ完了メッセージを出すやつみたい。

面白いのは174行目から178行目あたり。

sed -i 's| init=/usr/lib/raspi-config/init_resize.sh||' /boot/cmdline.txt
if ! grep -q splash /boot/cmdline.txt; then
  sed -i "s/ quiet//g" /boot/cmdline.txt
fi
sync

/boot/cmdline.txtからinit=/usr/lib/raspi-config/init_resize.shを消して次から実行されないようにしている。

自動拡張を無効にしてみる

とりあえず/boot/cmdline.txtからinit=/usr/lib/raspi-config/init_resize.shを削除して、 Raspberry PIに入れて起動してみた。

初回起動を終えた後に改めてfdiskで確認すると、自動拡張されていないことが確認できる。

$ sudo fdisk -l /dev/mmcblk0
Disk /dev/mmcblk0: 3.7 GiB, 3965190144 bytes, 7744512 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7ee80803

デバイス       起動 Start 最後から セクタ  Size Id タイプ
/dev/mmcblk0p1       8192    98045   89854 43.9M  c W95 FAT32 (LBA)
/dev/mmcblk0p2      98304  3645439 3547136  1.7G 83 Linux

この状態で、raspi-configの7 Advanced Options -> A1 Expand Filesystemすれば、昔のRaspbianのように手動で拡張できる。

resize2fsの実行はどうなっているか調べる

通常、パーティションをリサイズしたあとはOSを再起動してからresize2fsをしなければいけないはずなんだけど、 それはどこでやっているのだろうと思ってraspi-configのExpand Filesystem機能を調べてみた。

raspi-config自体はgithubリポジトリがあったのでそこのraspi-configのソースを読んだ。

github.com

Expand Filesystem機能はdo_expand_rootfsで実装されていて、その中のコードを読んでみたら、fdiskでパーティションを拡張した後に、/etc/init.d/resize2fs_onceを作成してupdate-rc.dでサービスに登録し、再起動後に実行させていることがわかった。

resize2fs_onceはサービスが起動されたら、resize2fsをrootfsパーティションに対して行い、自分自身のサービス登録を解除して、ファイル削除するようになっている。

RaspbianのOSイメージを焼いたばかりのSDカードでrootfsをマウントして/etc/init.dの中を調べてみると、確かにresize2fs_onceが入っていた。

OSイメージに入っていた/etc/init.d/resize2fs_onceはこんな感じになっていた。

#!/bin/sh
### BEGIN INIT INFO
# Provides:          resize2fs_once
# Required-Start:
# Required-Stop:
# Default-Start: 3
# Default-Stop:
# Short-Description: Resize the root filesystem to fill partition
# Description:
### END INIT INFO
. /lib/lsb/init-functions
case "$1" in
  start)
    log_daemon_msg "Starting resize2fs_once"
    ROOT_DEV=$(findmnt / -o source -n) &&
    resize2fs $ROOT_DEV &&
    update-rc.d resize2fs_once remove &&
    rm /etc/init.d/resize2fs_once &&
    log_end_msg $?
    ;;
  *)
    echo "Usage: $0 start" >&2
    exit 3
    ;;
esac

Raspbianの初回起動時には必ずこのサービスが動くので、rootfsのパーティションに対してresize2fsが行われる。

/etc/init.d/resize2fs_onceもスパイ映画の「なお、このテープは自動的に消滅する」みたいな作りになっていて面白い。

まとめ

Raspbianのrootfsの初回起動時自動拡張は/usr/lib/raspi-config/init_resize.sh/etc/init.d/resize2fs_onceの合わせ技で実現されていることがわかった。

Elmのupdate: Msg -> Model -> (Model, Cmd Msg)にあこがれてJavaScriptの状態管理用のライブラリ作った

作ったのはこれ。

www.npmjs.com

動機

普段Reduxで非同期なことをやる時にredux-thunkミドルウェアを使っている。

で、次のようなことをするときにいつも悩む。

  • 非同期にデータを取ってくるアクションで取得前にローディングマスクを表示し、取得完了後にローディングマスクを非表示にする。
  • なんらかのメッセージ(完了したとかエラーになったとか)をToast的に表示して、数秒後に消す。

例えばローディングマスクのやつだと、だいたい次のように実装している。

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const fetchMessage = () => {
  return new Promise(resolve => {
    // サンプルなのでsetTimeoutでごまかし
    setTimeout(() => {
      resolve('hello');
    }, 1000);
  });
};

const load = () => {
  return async dispatch => {
    dispatch({ type: 'prepare-for-loading' });
    const msg = await fetchMessage();
    dispatch({ type: 'loaded', payload: msg });
  };
};

const store = createStore((state = { msg: '', loading: false }, action) => {
  switch (action.type) {
    case 'prepare-for-loading': {
      return {
        ...state,
        loading: true
      };
    }
    case 'loaded': {
      return {
        msg: action.payload,
        loading: false
      };
    }
    default: {
      return state;
    }
  }
}, applyMiddleware(thunk));

store.subscribe(() => {
  console.log(store.getState());
});

store.dispatch(load());

このとき気になるのが、loadの実装。

const load = () => {
  return async dispatch => {
    dispatch({ type: 'prepare-for-loading' });
    const msg = await fetchMessage();
    dispatch({ type: 'loaded', payload: msg });
  };
};

thunkなアクションとしてdispatchを受け取る関数を返し、非同期API実行の前後で2回dispatchしている。 これが1つのアクションで2つの仕事をしているように見えて、なんだかなーと思っていた。 テストを書くときも、この関数は2つアクションを返して、1つ目は...みたいになっていて、ますますなんだかなーと。

次のように中身を分離して使う側でそれぞれ呼び出すという手もあるが、これを並べて同じタイミングで実行するのか、 それとも1つ目のdispatchで変更されたStateのloading:trueが来たら2つ目を別のタイミングで実行するべきかなどで悩ましい。

store.dispatch({ type: 'prepare-for-loading'});

store.dispatch(() => {
  return async dispatch => {
    const msg = await fetchMessage();
    dispatch({ type: 'loaded', payload: msg });
  }
});

そんな感じで悶々としていた。

Elmとの出会い

ReduxがElmを参考にしたという話は知っていたけど、特別触ってみたりはしていなかった。 ある日rebuild.fmのエピソードで取り上げられているのを聴いて、ちょっと興味が湧いたので勉強してみた。

rebuild.fm

Elmアーキテクチャの簡単なやつを触って、updateインターフェイスupdate : Msg -> Model -> Model な辺りまでは、 確かにReduxっぽい...みたいな印象だった。

その後、commandを使う辺りまで進んで、インターフェイスupdate : Msg -> Model -> (Model, Cmd Msg)になった時に、自分がやりたかったのこれだなーと思った。

ローディングのやつの例でElmだと次のようになると思う(実際は非同期取得の結果の成否とかもっとcase増えると思うけど)。

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        PrepareForLoading ->
            ( { model | loading = True }, load )

        Loaded payload ->
            ( { model | msg = payload }, Cmd.none )

で、JavaScriptでこんな感じにやるには...と考えて冒頭に貼ったcycloneというライブラリを作った。

cycloneを使った場合

Reduxがupdate<S, A>(state: S, action: A) => Sなのに対して、cycloneはupdate<S, A>(state: S, action: A) => [S, A]というインターフェイスになっていて、更新された状態と次に実行するアクションをタプルで返すようにしてある。次に実行するアクションがない場合はnoneを返す。これはElmでのCmd.noneを意識した。

上のローディングマスクの例をcycloneで書き直すと次のようになる。

import { createStore, none } from '@ushiboy/cyclone';

const fetchMessage = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('hello');
    }, 1000);
  });
};

const prepareForLoading = () => ({ type: 'prepare-for-loading' });

const load = async () => {
  const msg = await fetchMessage();
  return { type: 'loaded', payload: msg };
};

const store = createStore({ msg: '', loading: false }, (state, action) => {
  switch (action.type) {
    case 'prepare-for-loading': {
      return [
        {
          ...state,
          loading: true
        },
        load()
      ];
    }
    case 'loaded': {
      return [
        {
          msg: action.payload,
          loading: false
        },
        none()
      ];
    }
    default: {
      return [state, none()];
    }
  }
});

store.subscribe(() => {
  console.log(store.getState());
});

store.dispatch(prepareForLoading());

prepareForLoadingを実行すると、updateはloadingをtrueにし、次に実行するloadを返す。 storeは更新された状態を通知しつつ、次に実行すべきアクションを実行する。

loadの前に実行する関数の名前をどうするか、名前お悩み問題が増えたような気がするけど、 アクション的には1つのアクションで1つの仕事をするようになったので、概ね満足。

その他の機能

Reducer

ついでにつけた機能として、Reducerでの分割統治はできるようにしておこうということでcombinereducerというAPIを用意した。

使用例は次の通り。

import { createStore, none, combine, reducer } from '@ushiboy/cyclone';

const store = createStore({ a: 0, b: 0, c: '' }, combine(
  reducer('a', (state, action) => {
    switch (action.type) {
     default: {
        return [state, none()];
      }
    }
  }),
  reducer('b', (state, action) => {
    switch (action.type) {
      default: {
        return [state, none()];
      }
    }
  }),
  reducer('c', [ 'a', 'b' ], (state, action, a, b) => {
    switch (action.type) {
      default: {
        return [state, none()];
      }
    }
  })
));

Reducerの定義はreducer関数に対象とするStateのキー名とupdate関数を渡して行うようにした。それをcombine関数で統合してひとつのupdate関数にする。

Reducerで分割統治すると、後々この状態はこっちでも参照したい...がでるので、reducer関数の第2引数に参照したい他の状態のキー名リストを渡すとupdate関数の第3引数以降で受け取れるようにした(上の例だとcのReducerでabの状態を参照している)。

Extra Argument

もうひとつ、redux-thunkのwithExtraArgument的な機能も足した。

createStoreの第3引数にアクション実行時に利用したいオブジェクトなどを設定すると、アクション側で受け取って使用できる。

これはWebAPIみたいな、テストの時はインターフェイス同じなモックに差し替えたいものを使うことを目的としている。

サンプルコードは次の通り。

const greet = () => async ({ sleep }) => {
  await sleep(1000);
  return { type: 'greet', payload: { msg: 'hello' } };
};

const store = createStore(
  { word: '', waiting: false },
  (state, action) => {
    switch (action.type) {
      default: {
        return [state, none()];
      }
    }
  },
  {
    sleep(time) {
      return new Promise(resolve => {
        setTimeout(() => {
          resolve();
        }, time);
      });
    }
  }
);

greetアクションはcreateStoreの第3引数で指定したsleepAPIを持つオブジェクトを受け取って使用している。

まとめ

Elmの学習はupdateのインターフェイス以外にも参考になることが結構あったのでやってよかった。

作ったライブラリは、ひとまず意図した動きになっているけど、中身の実装でまだ迷っている部分があるので、 その辺はドッグフーディングしながら様子見。

E2Eテストの実行時間を1/2とか1/4に縮めた

前回も触れたけど、普段Seleniumを使ってpythonでE2Eテストを書いている。

E2Eテストの接続先サーバはdocker-composeを使って必要なものをガッと立ち上げていて、 テスト内でredisやDBに直接接続してデータの初期化をしながらやっている。

使っているdocker-compose.ymlはだいたい次のような感じ。

version: '2'
services:
  web:
    container_name: "test-web"
    image: nginx:1.11
    ports:
      - "8080:80"
    volumes:
      - /etc/localtime:/etc/localtime
      - ./build:/usr/share/nginx/html
      - ./fixture/nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app
  app:
    container_name: "test-app"
    image: myapp
    volumes:
      - /etc/localtime:/etc/localtime
      - ./fixture/run.sh:/myapp/run.sh
      - ./fixture/production.ini:/myapp/production.ini
    depends_on:
      - redis
      - db
  redis:
    container_name: "test-redis"
    ports:
      - "6379:6379"
    volumes:
      - /etc/localtime:/etc/localtime
    image: redis
  db:
    container_name: "test-db"
    image: postgres:9.5
    ports:
      - "5432:5432"
    volumes:
      - /etc/localtime:/etc/localtime
      - ./fixture/postgresql/initdb:/docker-entrypoint-initdb.d
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_USER=postgres

webサーバとしてnginxがあり、WEB APIのためのappサーバへのリクエストをリバースプロキシする。 appサーバはAPIのリクエストが来たら、dbやredisに対してアレコレする。

これをdocker-compose up -dしたあとにpy.test testして実行する...という感じにやっていた。

あるアプリケーションで、機能が増えるに連れてだんだんとテストの量が増えていき、 現在は150件くらいのテストを実行するのに15分近くかかるようになっていた。

DBコンテナの/var/lib/postgresql/dataをtmpfsにし、selenium.webdriver.support.ui.WebDriverWaitpoll_frequencyパラメータ(UIの変化を待機する条件をチェックする周期)をデフォルトの0.5秒から小さくして、 トータル時間12分位までには縮まったものの、劇的な効果は得られなかった。

そこで以前から考えていたpytest-xdistを使ったテストの並列化を試してみた。

pytest-xdistとdocker-composeの複数起動

pytest-xdistを使うとpy.test -n 2 testのようにして、-nオプションでワーカーの数を指定してテストを並列に実行できるようになる。

ただし、E2Eテストでこれをそのままやってしまうと、あるワーカーがテストを行っている最中に別のワーカーがDBの初期化をしてしまうので、 ワーカー毎に接続先のサーバ側を分ける必要があり、そのままでは使えない。

docker-composeは-pオプションでプロジェクト名を指定して複数起動することができるので、 docker-compose.yml環境変数で各種ポートを指定できるように修正を加えて、プロジェクトごとにポートを変えて起動できるようにした。

修正したdocker-compose.ymlファイルは次のような感じになった。

version: '2.1'
services:
  web:
    container_name: "test-web-${ID:-1}"
    image: nginx:1.11
    ports:
      - "${WEB_PORT:-8080}:80"
    volumes:
      - /etc/localtime:/etc/localtime
      - ./build:/usr/share/nginx/html
      - ./fixture/nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app
  app:
    container_name: "test-app-${ID:-1}"
    image: myapp
    volumes:
      - /etc/localtime:/etc/localtime
      - ./fixture/run.sh:/myapp/run.sh
      - ./fixture/production.ini:/myapp/production.ini
    depends_on:
      - redis
      - db
  redis:
    container_name: "test-redis-${ID:-1}"
    ports:
      - "${REDIS_PORT:-6379}:6379"
    volumes:
      - /etc/localtime:/etc/localtime
    image: redis
  db:
    container_name: "test-db-${ID:-1}"
    image: postgres:9.5
    ports:
      - "${DB_PORT:-5432}:5432"
    tmpfs:
      - /var/lib/postgresql/data
    volumes:
      - /etc/localtime:/etc/localtime
      - ./fixture/postgresql/initdb:/docker-entrypoint-initdb.d
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_USER=postgres

コンテナ名が被らないように名称の末尾に付けるためのIDと各ポートWEB_PORTDB_PORTREDIS_PORT環境変数で渡せるようにした。

これで次のようにコマンドを叩けば同じ構成の環境をポートを変えて複数立ち上げられる。

$ ID=1 WEB_PORT=8080 DB_PORT=54320 REDIS_PORT=63790 docker-compose -p s1 up -d
$ ID=2 WEB_PORT=8081 DB_PORT=54321 REDIS_PORT=63791 docker-compose -p s2 up -d

pytest-xdistによって、テスト実行中のワーカーにはPYTEST_XDIST_WORKER環境変数が渡される。 ここから、gw0gw1といった自分のワーカーIDを取得できる。

このワーカーIDと接続するサーバのポートを関連付けて使うようにすれば、ワーカー毎に異なるサーバへ接続して互いに干渉することなくテストできる。

ワーカーと接続サーバの関連付け

docker-composeの環境変数に渡す値と、ワーカー内で使用するポートの値を関連付けて管理するために、次のようなe2e.config.yml設定ファイルを用意した。

workers:
  - web_port: 8080
    redis_port: 63790
    db_port: 54320
  - web_port: 8081
    redis_port: 63791
    db_port: 54321
  - web_port: 8082
    redis_port: 63792
    db_port: 54322
  - web_port: 8083
    redis_port: 63793
    db_port: 54323

ワーカーごとにweb_portredis_portdb_portを記述できるようにした。

そして、この設定ファイルを読み込んで使うためのAPIenvironment.pyとして用意した。

import yaml
import subprocess
import os

def load_e2e_config(filepath):
    with open(filepath, 'r') as f:
        data = yaml.load(f, Loader=yaml.SafeLoader)
    config = dict()
    workers = data.get('workers', [])
    for i, w in enumerate(workers):
        w['id'] = i + 1
        config['gw%d' % i] = w
    return config

def docker_compose_up(config):
    for name, c in config.items():
        env = os.environ.copy()
        env['ID'] = str(c['id'])
        env['WEB_PORT'] = str(c['web_port'])
        env['REDIS_PORT'] = str(c['redis_port'])
        env['DB_PORT'] = str(c['db_port'])
        subprocess.run(['docker-compose', '-p', name, 'up', '-d'], env=env)

def docker_compose_down(config):
    for name, c in config.items():
        subprocess.run(['docker-compose', '-p', name, 'down'])

load_e2e_config関数はe2e.config.ymlを読み込んでワーカーIDをキーとした設定オブジェクトに変換する。

docker_compose_up関数はload_e2e_config関数で作った設定オブジェクトを利用して、必要な数だけdocker環境を立ち上げる。 docker_compose_down関数は立ち上げていたdocker環境を停止するために使う。

これを使ってサーバ起動用スクリプトstartup-serversとして用意した。

#!/usr/bin/env python3
from myapp.environment import load_e2e_config, docker_compose_up

if __name__ == '__main__':
    config = load_e2e_config('./e2e.config.yml')
    docker_compose_up(config)

同じくサーバ停止用スクリプトhalt-serversとして用意した。

#!/usr/bin/env python3
from todo.environment import load_e2e_config, docker_compose_down

if __name__ == '__main__':
    config = load_e2e_config('./e2e.config.yml')
    docker_compose_down(config)

テストケースのコードではsetUpメソッドでload_e2e_config関数を使って設定ファイルを読み込み、PYTEST_XDIST_WORKER環境変数から自身のワーカーIDを取得して、接続先のWeb、DB、Redisのポートを取り出して使うようにした。

    def setUp(self):
        target_port = '8080'
        db_port = '5432'
        redis_port = '6379'

        worker = os.environ.get('PYTEST_XDIST_WORKER')
        if worker is not None:
            config = load_e2e_config(E2E_CONFIG_PATH)
            c = config[worker]
            target_port = str(c['web_port'])
            db_port = str(c['db_port'])
            redis_port = str(c['redis_port'])

テストの実行

用意したスクリプトと設定で、テストの実行を行った。

$ ./startup-servers
$ HEADLESS=1 py.test -n 4 test
$ ./halt-servers

並列化なしで12分位だった実行時間が、ワーカー数2つの並列化で6分位、ワーカー数4つで3分位で終わるようになった。

ちなみに使ったWebDriverはChromeだけで、FirefoxIE、Edgeは試していない。 Firefoxは大丈夫そうな気がするけど、IEとEdgeは駄目なんじゃないかと予想している。

まとめ

並列化して実行時間をいい感じに縮められた。

前回の参考リポジトリにも反映しておいたけど、こちらはそもそもDBとか使わないやつなのであまり恩恵はない。 とりあえずテンプレート用に入れておいた。

github.com

こちらは並列化なしの結果が次のような感じ。

$ HEADLESS=1 py.test todo/                     
============================================================== test session starts ===============================================================
platform linux -- Python 3.7.1, pytest-4.2.1, py-1.8.0, pluggy-0.9.0
Using --randomly-seed=1551505883
rootdir: /work/my-todo-2019-early/e2e, inifile:
plugins: xdist-1.26.1, randomly-1.2.3, forked-1.0.2
collected 5 items                                                                                                                                

todo/specs/todos_test.py .....                                                                                                             [100%]

=========================================================== 5 passed in 16.32 seconds ============================================================

並列化あり(ワーカー数4つ)だと結果が次のような感じになる。

$ HEADLESS=1 py.test -n 4 todo/
============================================================== test session starts ===============================================================
platform linux -- Python 3.7.1, pytest-4.2.1, py-1.8.0, pluggy-0.9.0
Using --randomly-seed=1551505959
rootdir: /work/my-todo-2019-early/e2e, inifile:
plugins: xdist-1.26.1, randomly-1.2.3, forked-1.0.2
gw0 [5] / gw1 [5] / gw2 [5] / gw3 [5]
.....                                                                                                                                      [100%]
============================================================ 5 passed in 6.59 seconds ============================================================

2019年現時点の自分のWebフロントエンド開発環境

数年前、年末にその時点での自分のWebフロントエンド開発まわりの環境を記録用にまとめていたけど、 それからすっかり怠けてしまい、去年の暮れには久しぶりにやろうと思っていたものの、気がついたらすっかり年が明けていた。

最近色々ずっとアレな感じでメンタル低空飛行気味なんだけど、このまま朽ち果てていくのは嫌なので、気分転換兼ねてまとめてみた。

今回はサンプルとしてTodoアプリケーションを作りながらまとめた。

github.com

前置き

自分が普段開発しているWebフロントエンドのボリュームは2,3万行くらいのコード量のSingle Page Application。 一般公開されるWebサービスではなくて、ログインして使う業務アプリケーションみたいなのが多め。 Web API叩いて必要なデータをバックエンドとやりとりしながら、JavaScriptで動的にすべての画面を描いていく感じ。 初期描画のパフォーマンス要求とかは今のところそこまで気にしなくていい程度のもの。 それをフロントエンドチーム(自分一人)で開発・メンテしてる。

開発スタイルはLinuxデスクトップで、フルスクリーン表示したターミナルをtmuxで画面分割して、コマンド叩きつつvimでコード書いてる。 動作確認はChromeでDevTools開きながらやって、大枠できてから他のブラウザで確認する感じ。

そんな人間の環境なので、これだとできないことがあるとか、チームでやったらやりにくい、うまくいかないとかそういうのはあると思う。

偏りがあるのはさておき、とりあえずJavaScriptCSS、開発Webサーバ、テストあたりについて順にまとめていく。

JavaScriptまわり

「FlowからTypeScriptへ移行した」みたいな記事をよく見かけるようになった昨今で、 @babel/preset-typescriptが出てきたというのもあり、先のこと考えたらTypeScriptに行っとくべきかなーと思いつつ、 とりあえずBabel + Flowで型付けたECMAScriptで書き続けている。

babel.config.jsの設定はこんな感じ。

module.exports = {
  'presets': [
    '@babel/preset-env',
    '@babel/preset-react',
    '@babel/preset-flow'
  ],
  'env': {
    'development': {
      'presets': [
        'power-assert'
      ]
    },
    'production': {
      'plugins': [
        [
          'react-remove-properties',
          {
            'properties': ['data-test']
          }
        ]
      ]
    }
  }
}

ViewのライブラリにReactを使ってるので@babel/preset-reactを追加している。power-assertreact-remove-propertiesに関しては、後でテストの方で説明する。

Flowで外部ライブラリの型定義を扱うときに、flow-typedを真面目には使っていなくて、必要に応じてリポジトリから取ってきて定義ファイルを設置してみているけど、ほとんどはdeclare module.exports: any;で握りつぶしている。

バンドルはwebpackのbabel-loader頼み。

webpack.config.jsのbabel-loader部分はこんな感じ。

    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          loader: 'babel-loader'
        },

特に変わったことはしていない。

コードフォーマットをPrettierにまかせていて、ESlintは細かく設定していない。

.eslintrc.jsonはこんな感じ。

{
  "env": {
    "browser": true,
    "commonjs": true,
    "es6": true,
    "node": true,
    "mocha": true
  },
  "globals": {
    "ENABLE_SERVICE_WORKER": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended"
  ],
  "parser": "babel-eslint",
  "parserOptions": {
    "ecmaFeatures": {
      "jsx": true
    },
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "plugins": [
    "flowtype",
    "react"
  ],
  "rules": {
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  }
}

ReactとFlow使ってるぶんの設定があるくらい。no-unused-varsの検出できれば良いくらいの感覚で使っている。

実はコードフォーマット系は苦手意識があったけど、去年Elmの勉強した時にelm-format使ったらすごい楽で、 フォーマットに頭使わなくていいことの良さを思い知って、JavaScriptでもやるようになった。

Prettierはシングルクォートについて以外、特に指定してなくてnpm scriptで使うためにpackage.jsonに次のように設定している。

"scripts": {
    "lint": "eslint src",
    "format-js": "prettier --single-quote --write 'src/**/*.js'",

ファイル保存時に自動整形はやってなくて、gitコミットする前にフォーマットして、テストとか通るの確認してからコミットしてる。

CSSまわり

LESSをやめて、Sassをやめて、PostCSSを使うようにしているけど、Bootstrap依存症なのでSassも入れることになり、結局Sassで書いている。 Bootstrapが次のバージョンからPostCSSにするとか言ってる記事を見た気がするので、次バージョンあげたらSassはやめるかもしれない。

バンドルはwebpack頼み。昔はwebpackはJavaScriptのバンドルのみに使って、CSSまわりはgulpと色々組み合わせてやっていたけど、後で書く開発Webサーバのこともあり、もうwebpackで全部やってる。

Reactを使っているので、styled-componentsとかやったほうがいいのかなーと思いつつも、 そもそもデザインやCSS得意じゃないので、デザイナーが作ってくれたCSSを取り込みながらやるスタイルを想定してCSSは別にしてる。 スコープ問題とかで苦しみたくないし、BEM記法は性に合わないので色々工夫してみていたけど、ひとまずCSS Modulesってやつでやってみている。

webpack.config.jsのその辺りの設定は次の通り。

    module: {
      rules: [
        {
          test: /src\/app\.scss$/,
          use: [
            { loader: MiniCssExtractPlugin.loader },
            {
              loader: 'css-loader'
            },
            {
              loader: 'postcss-loader',
              options: {
                plugins: function () {
                  return [
                    require('precss'),
                    require('autoprefixer')
                  ];
                }
              }
            },
            {
              loader: 'sass-loader'
            }
          ]
        },
        {
          test: /\.(css|scss)$/,
          include: /src\/presentation/,
          use: [
            { loader: MiniCssExtractPlugin.loader },
            {
              loader: 'css-loader',
              options: {
                modules: true,
                localIdentName: mode === 'production' ?
                  '[hash:base64]' : '[path][name]-[local]-[hash:base64:5]'
              }
            },
            {
              loader: 'postcss-loader',
              options: {
                plugins: function () {
                  return [
                    require('precss'),
                    require('autoprefixer')
                  ];
                }
              }
            },
            {
              loader: 'sass-loader'
            }
          ]
        }

src/app.scsssrc/presentation配下の.(css|scss)で分けてルールを設定している。

src/app.scssの方は次のようになっていて、BootstrapやFontAwesomeのスタイルはCSS Modulesの対象外にしたいので、こんな分け方になっている。

@import "./scss/_variables.scss";
@import "~bootstrap/scss/bootstrap";
$fa-font-path: "../node_modules/@fortawesome/fontawesome-free/webfonts";
@import "~@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import "~@fortawesome/fontawesome-free/scss/solid.scss";
@import "~@fortawesome/fontawesome-free/scss/brands.scss";
@import "~react-toastify/scss/main.scss";

アプリケーションのViewコンポーネントのスタイルはsrc/presentation配下にコンポーネントごとにディレクトリを切って、対になるJavaScriptファイルと並べておいている。

Header
├── Header.js
└── Header.scss

こちらはCSS Modulesの対象にしている。

例えばHeader.scssの内容は次のようになっている。

@import "../../../scss/_variables.scss";

.head {
  background-color: $header-bg-color;
  .sub {
    color: $brand-title-color;
    &:hover {
      color: $brand-title-color;
    }
  }
}

これをHeader.js側で次のようにimport style from './Header.scss';して使っている。

/* @flow */
import React from 'react';
import style from './Header.scss';
import '../../../images/icon.svg';

export const Header = () => {
  return (
    <nav className={`navbar navbar-light ${style.head}`}>
      <span className={`navbar-brand ${style.sub}`}>
        <img
          src="images/icon.svg"
          width="30"
          height="30"
          className="d-inline-block align-top"
          alt=""
        />{' '}
        Todo
      </span>
    </nav>
  );
};

mini-css-extract-pluginを使って、ビルドしたらcssファイルとして出力されるようにしているけど、webpackのバージョンが上がる度にこのあたりは躓いているので、毎回ヒヤヒヤする。

開発Webサーバ

以前はgulpの中でconnectとかlive-reloadとか使ってやっていた。今ではwebpack-dev-serverでWeb APIのプロキシとか、 History APIのフォールバックとか全部任せてやっている。

これとCSSの件を合わせて、gulpを完全にやめるきっかけとなり、npm scriptとwebpackでだいたい何とかするようになった。

webpack.config.jsのwebpack-dev-server周りの設定は次の通り。

      devServer: {
        contentBase: './src',
        inline: true,
        host: '0.0.0.0',
        port: 8080,
        disableHostCheck: true,
        historyApiFallback: true,
        stats: {
          version: false,
          hash: false,
          chunkModules: false
        }
      },
      devtool: 'source-map'

これをnpm startで起動できるようにpackage.jsonに次の通り設定している。

  "scripts": {
    "start": "webpack-dev-server",

テストまわり

単体テスト

Jestの話題を見かけるようになったなーと思いつつ、あいかわらずmochaとpower-assertでやってる。spyやstubが必要になったらsinonも使っている。 そのためbabel.config.jspower-assertの設定がしてある。

以前はtestファイルの置き場はアプリケーションとは別にtestディレクトリを切って、そちらにアプリケーションと同じ階層で置くようにしていた。 しかし、importの都合とかで最終的に面倒になってアプリケーションのファイルと同列に*.spec.jsとして置くようになった。

domain
├── Todo.js
└── Todo.spec.js

*.spec.jsファイルはESLintの対象にしたくないので、.eslintignoreファイルに次のように設定している。

src/**/*.spec.js

テストの実行はnpm testで行うため、package.jsonに次のように設定してある。

"scripts": {
    "test": "mocha --require src/testSetup.js --recursive './src/**/*.spec.js'",

テストもECMAScriptで書きたいので、mochaに渡している--requireパラメータのsrc/testSetup.jsファイルで次のようにしている。

require('@babel/register')();
require('@babel/polyfill');

テストの実行はNode環境でのみにしていて、ブラウザ環境で実行するKarmaやtestemは使わなくなった。 ViewのテストはEnzymeとjsdomで頑張ってみたこともあるけど、極力しないようになった。

AjaxなどのWEB API叩く系は、以前はfetch-mockを使ってfetchを差し替えたりしていたが、 今はアプリケーションのコードでfetchを直接使わずに一枚かぶせたインターフェイスを定義して、テストの時だけ差し替えるようにしている。

例えば、今回のTodoアプリケーションだとlocalStorageを直接使わずにStorageInterfaceのオブジェクトとして、 テストの時はMapをベースにしたMemoryStorageに差し替えている。

src/infrastructure/LocalStorageRepository.jsの次のあたり。

export class LocalStorageRepository implements TodoRepository {
  _storage: StorageInterface;

  constructor(storage: StorageInterface) {
    this._storage = storage;
  }

  // 中略
}

interface StorageInterface {
  +length: number;
  getItem(key: string): ?string;
  setItem(key: string, data: string): void;
  clear(): void;
  removeItem(key: string): void;
}

export class MemoryStorage implements StorageInterface {
  _map: Map<string, string>;

  constructor() {
    this._map = new Map();
  }

  get length(): number {
    return this._map.size;
  }

  getItem(key: string): ?string {
    return this._map.get(key);
  }

  setItem(key: string, data: string): void {
    this._map.set(key, data);
  }

  clear(): void {
    this._map.clear();
  }

  removeItem(key: string): void {
    this._map.delete(key);
  }

  key(index: number): ?string {
    return Array.from(this._map.keys())[index];
  }
}

E2Eテスト

Seleniumを使ってE2Eテストをわりと書いている。 E2Eテストはコスト高いけど、手作業でする簡単な動作確認を自動で繰り返しできるようにしておく程度のモチベーションで書いている。

SeleniumJavaScriptで使うとAPIが非同期系多すぎて書きづらいので、pythonで書いている。テストにはpy.testを使っている。

環境はe2eディレクトリ配下にまとめている。

e2e
├── drivers
│   ├── chromedriver
│   └── geckodriver
├── requirements.txt
├── todo
│   ├── __init__.py
│   ├── datas
│   ├── page_objects
│   └── specs
└── venv

driversディレクトリにはWebドライバーをダウンロードしてきたものを置く。ブラウザがアップデートするとドライバーが合わなくなるので、 都度更新できるように中身はgitignore対象にしてある。

テストケースはe2e/todo/specs下に置いてある。E2Eテストを書くときにドライバーでのDOMの取得をテストにベタ書きすると 後々非常に辛くなるので、ページオブジェクトパターンを使ってテストケースではDOMを直接触らずに、 ページオブジェクト経由でシナリオに沿って画面操作して取得した結果をアサーションにかけている。

ページオブジェクトはe2e/todo/page_objects配下に置いてあり、例えばTodo一覧のページオブジェクト(todo_page.py)は次のようになっている。

from . import PageObject

class TodoListPage(PageObject):

    @property
    def el(self):
        return self.driver.find_element_by_css_selector('div[data-test="todo-list"]')

    def wait_show_todo_list(self):
        self._wait_show('div[data-test="todo-list"]')
        return self

    def wait_loading_complete(self):
        return self._wait_loading_complete(self.el)

    def get_todo_rows(self):
        rows = self.el.find_elements_by_css_selector('tr[data-test="todo-list-row"]')
        return list(map(lambda el: TodoListRow(self.driver, el), rows))

    def click_create_todo_button(self):
        b = self.el.find_element_by_css_selector('a[data-test="create-todo-button"]')
        b.click()
        return TodoFormPage(self.driver)

    def click_all_tab(self):
        t = self.el.find_element_by_css_selector('a[data-test="all-tab"]')
        t.click()
        return self

    def click_active_tab(self):
        t = self.el.find_element_by_css_selector('a[data-test="active-tab"]')
        t.click()
        return self

    def click_completed_tab(self):
        t = self.el.find_element_by_css_selector('a[data-test="completed-tab"]')
        t.click()
        return self

これを使ったTodo一覧の表示確認用のテスト(todos_test.py)は次の通り。IndexPageのopenでTodoListPageが取得できるようになっている。

class TodosTest(E2ETest):

    def test_show_todo_list_rows(self):
        p = IndexPage(self.driver, self.target_host)\
                .open()

        init_data(self.driver)

        p = p.reload()\
                .wait_show_todo_list()

        rows = p.get_todo_rows()
        assert len(rows) == 3
        r1, r2, r3 = rows;
        assert r1.get_title() == 't1'
        assert r2.get_title() == 't2'
        assert r3.get_title() == 't3'

画面のHTMLの構造が変わっても、機能自体が変わっていなければ、ページオブジェクトの修正だけでテストは変えずにすませられる。 それと、テストケース間でページオブジェクトを使いまわせるので重宝している。

CSS Modulesを使っている関係上、DOMへのアクセスにセレクタCSSクラスを使いにくい(テスト用の余計なCSSクラスとか付けたくない) のでDOMにはdata-test属性を付けてアクセスできるようにしている。

TodoListのViewだと次のようにdata-test="todo-list"とかしている。

/* @flow */

// 省略

export class TodoList extends React.Component<Props> {

  // 省略
  
  render() {
    const { visibleRecords, loading, filter } = this.props.todos;
    const rows = visibleRecords.map((t, i) => {
      return <TodoRow key={i} todo={t} />;
    });
    return (
      <div className={styles.todoList} data-test="todo-list">
        <div className="card">
          <div className="card-header">
            <ul className="nav nav-tabs card-header-tabs">

// 省略

babel.config.jsreact-remove-propertiesを利用していて、productionビルドの時はdata-test属性が取り除かれるようにしてある。

ビルド用のタスクはpackage.jsonに次のように設定している。

  "scripts": {
    "clean": "rm -rf build",
    "build": "npm run clean && NODE_ENV=production webpack",
    "build-testing": "npm run clean && NODE_ENV=testing webpack",

npm run buildでビルドするとproductionビルドをする。 npm run build-testingでビルドするとdata-test属性がついたままのビルドになるので、E2Eテストの時はこちらを使う。

今回のサンプルアプリケーションではWEB APIを叩くことも無いし、バックエンドにDBやredisがあったりするわけでもないので、 npmで立ち上げたWebサーバにアクセスするだけにしているけど、 業務ではバックエンドをDockerで動かせるようにしておいて、docker-composeでサーバ環境を立ち上げて、 テスト実行ごとにDB初期化とかデータ投入とか色々やって動かしている。

フロントエンドチーム外のメンバーへの提供

フロントエンドのgitリポジトリをクローンしてもらって各自にビルドしてもらうやり方だと、普段フロントエンドの開発をしてないメンバーの環境ではNodeのバージョンが古かったり、npmのバージョンが古かったりして、「ビルドできないんだけど...」と言われることが多かった。

そこでDocker内でビルドできるようにビルド用のDockerイメージを作成するスクリプト(create_docker_image.sh)と、 そのイメージを使ってビルドするスクリプト(build_on_docker.sh)を同梱するようにしている。

ビルド用のDockerイメージを作成するcreate_docker_image.shは次のようにしてある。

#!/bin/sh
SCRIPT_DIR=$(cd $(dirname ${0}) && pwd)
cd "$SCRIPT_DIR/.."

DOCKER_IMAGE_NAME="nodejs10-for-frontend-build"
IMAGE=`docker images | awk '{print $1}' | grep $DOCKER_IMAGE_NAME`
if [ "$IMAGE" != "$DOCKER_IMAGE_NAME" ]; then
    docker build -t $DOCKER_IMAGE_NAME .
fi

作成するDockerイメージのDockerfileは次の通り。

FROM ubuntu:18.04

RUN apt-get update && \
    apt-get install -y locales && \
    locale-gen en_US.UTF-8

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

RUN sed -i.bak -e "s%http://archive.ubuntu.com%http://jp.archive.ubuntu.com%g" /etc/apt/sources.list && \
    apt-get update && \
    apt-get install -y unzip wget git curl gnupg && \
    curl -sL https://deb.nodesource.com/setup_10.x | bash - && \
    apt-get install -y nodejs && \
    mkdir /.npm && \
    chmod 777 /.npm && \
    mkdir /.config && \
    chmod -R 777 /.config

node用の公式イメージでも良さそうだけど、とりあえず昔からの流れでubuntuベースで作ってる。

Docker内でフロントエンドのビルドをするbuild_on_docker.shは次の通り。

#!/bin/sh

SCRIPT_DIR=$(cd $(dirname ${0}) && pwd)
cd "$SCRIPT_DIR/.."
DOCKER_IMAGE_NAME="nodejs10-for-frontend-build"
PROJECT_ROOT_DIR=`pwd`
USER_ID=`id -u`
GROUP_ID=`id -g`

RUN_ACTION=`cat << EOS
    cd /tmp/frontend && \
    npm install && \
    npm run build
EOS`
docker run -u $USER_ID:$GROUP_ID \
    -v $PROJECT_ROOT_DIR:/tmp/frontend:rw \
    --rm $DOCKER_IMAGE_NAME /bin/sh -c "$RUN_ACTION"
rm -rf Todo Todo.zip Todo-*.zip
mv build Todo
REV=`git rev-parse HEAD`
echo $REV > Todo/GIT_REVISION
zip Todo.zip -r Todo
cp Todo.zip "Todo-$REV.zip"

ビルドのついでにzipで固めることと、gitのリビジョンつけたzipも作るようにしてある。

とりあえずこれらを使ってもらうようにしたが、そもそもビルド済みのを配れるようにしておけば良いのでは...と思い、 JenkinsなどのCIでこのスクリプトを使ってビルドし、zipをs3とかにアップロードして配布している。

まとめ

昔と比べるとgulpを捨ててwebpackベッタリになった。

こうして書き出してみると、偏った環境でやっているせいか、甘えてゆるふわになっている部分が結構ある気がする。 必要に応じてそのあたりを引き締めていくのが今後の課題となりそう。

とりあえず色んな動向をうかがいつつ、自分のペースで調整していくつもり。

Selenium WebDriverでテスト書くのにAsync/Awaitを使ってみた

E2Eテストを書くときにSelenium WebDriverを使っているんだけど、テストをJavaScriptで書くと、下みたいな感じでPromiseのチェーンだらけになるのがあまり好みじゃなかった。

const assert = require('power-assert');
const chrome = require('selenium-webdriver/chrome');
const {Builder, By, Key} = require('selenium-webdriver');

describe('Promiseベースで書いたテスト', function () {

  this.timeout(20000);

  let driver;

  beforeEach(() => {
    driver = new Builder()
      .forBrowser('chrome')
      .setChromeOptions(new chrome.Options().headless())
      .build();
  });

  afterEach(() => {
    driver.quit();
  });

  it('then,then,then', () => {
    return driver.get('https://www.google.com').then(() => {
      driver.findElement(By.name('q')).sendKeys('webdriver');
      return driver.sleep(1000);
    }).then(() => {
      driver.findElement(By.name('q')).sendKeys(Key.TAB);
      driver.findElement(By.name('btnK')).click();
      return driver.sleep(1000);
    }).then(() =>  {
      return driver.getTitle();
    }).then((title) => {
      assert(title === 'webdriver - Google 検索');
    });
  });

});

なので、普段はPythonとか別の言語で書いていた。 ECMAScript2017でAsync/Await入るの見た時に、雰囲気変わるかなーと思いつつ試さずに放置してたので、最近はじめた気分転換のついでに試してみた。

こんな感じになった。

const assert = require('power-assert');
const chrome = require('selenium-webdriver/chrome');
const {Builder, By, Key} = require('selenium-webdriver');

describe('Async/Awaitで書いたテスト', function () {

  this.timeout(20000);

  let driver;

  beforeEach(() => {
    driver = new Builder()
      .forBrowser('chrome')
      .setChromeOptions(new chrome.Options().headless())
      .build();
  });

  afterEach(() => {
    driver.quit();
  });

  it('await,await,await', async () => {
    await driver.get('https://www.google.com');
    driver.findElement(By.name('q')).sendKeys('webdriver');
    await driver.sleep(1000);

    driver.findElement(By.name('q')).sendKeys(Key.TAB);
    driver.findElement(By.name('btnK')).click();
    await driver.sleep(1000);

    const title = await driver.getTitle();
    assert(title === 'webdriver - Google 検索');
  });

});

多少マシになった感じ?

ちなみにPythonで書くとこんな感じ。

import unittest
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import os
import time

class GoogleSearch(unittest.TestCase):

    def setUp(self):
        driver_path = os.path.join(os.path.dirname(__file__), 'chromedriver')
        options = webdriver.chrome.options.Options()
        options.add_argument('--headless')
        self.driver = webdriver.Chrome(executable_path=driver_path, chrome_options=options)

    def test_search_in_google_com(self):
        driver = self.driver

        driver.get('https://www.google.com')
        q = driver.find_element_by_name('q')
        q.send_keys('webdriver')
        time.sleep(1)

        q.send_keys(Keys.TAB)
        driver.find_element_by_name('btnK').click()
        time.sleep(1)

        assert driver.title == 'webdriver - Google 検索'

    def tearDown(self):
        self.driver.close()

if __name__ == '__main__':
    unittest.main()

もっと込み入った感じのを書いてみないとなんとも言えないけど、ありかなー?どうかなー?って感じ。

お試し用のリポジトリこちら

observable-storeとfreezable-storeというのを作ってみた話

きっかけ

ビューを作るのにReactを使っているとpropsで渡すものはなるべくシンプルなオブジェクトにしたいかな...みたいな気分になる。

(雑な例であまり良くないのだけれど)例えば、カウンタアプリケーションで現在のカウントを表示するビューみたいなのを作る時に、propsで渡されるのはCounterモデルのインスタンスで...とかではなくて、単純にcountプロパティを持つオブジェクトってだけにしたい。

// こうじゃなくて
function CounterView(props) {
  const { counter } = props;
  return <div>{counter.getCount()}</div>;
}

// こんな感じにしたい
function CounterView(props) {
  const { count } = props;
  return <div>{count}</div>;
}

propsで渡ってくるのはそういうオブジェクトだよということをPropTypesとかflowで保証して、キー名typoして残念undefined...みたいなのは防いでおく。ビュー側では参照しかしないので、とりあえず構造が合っていればモデルのこと知らなくていいみたいな感じにしたい。

逆に参照ではなくて状態を変えるところ、例えばカウンタのカウントを増やすみたいなのは、むき出しのJSONこねこねします...みたいなのじゃなくて、ちゃんとCounterモデルの操作にしたい。

またしても雑な例にすると、次みたいな感じでincrementメソッド呼ぶみたいにしたい。

const counter = new CounterModel(/* 初期化パラメータとか */);
counter.increment();

で、その辺りひっくるめてちょっと自分なりのやり方探してみようかなーと、モデルとビューの間に入って橋渡しするくんとしてobservable-storeというのを作ってみた。

observable-store

機能は次のような感じ。

  • 状態を保持する。
  • 保持している状態は参照可能にする。
  • 保持している状態は特定の方法でのみ変更可能にする。
  • 保持している状態が変わったらオブザーバーに通知する。

APIはObject.assignと短命に終わったObject.observeの間の子みたいにして、実装にはProxy使ってゴニョゴニョやった。

単純な使い方は次のような感じ

import createObservableStore from '@ushiboy/observable-store';

// observable-storeのインスタンスを生成
const store = createObservableStore({
  count: 0
});

// 保持している状態にはstateプロパティからアクセスする
const state = store.state;
console.log(`count: ${state.count}`); // count: 0;

// 状態の編集はassignメソッドから行う
store.assign({
  count: state.count + 1
});
console.log(`count: ${state.count}`); // count: 1;

// stateプロパティを直接変更しようとするとエラーになる
try {
  state.count = state.count + 1;  // throw Error
} catch (e) {
  console.log(`${e}`); // Error: Should use assign
}

// 変更を監視するオブザーバーの登録はobserveメソッドで行う
const observer1 = () => {
  console.log(`change: count:${state.count}`);
};
store.observe(observer1);

// オブザーバーの解除はunobserveメソッドで行う
store.unobserve(observer1);

当初はモデルを保持してモデルの変更を監視して...とか、参照の時にモデルのtoJSON呼んで...とか色々考えていたんだけど、モデル側の作り方を制限したくなくなってバッサリやめた。

で、これを使ってやりたかった感じにカウンタアプリ作ってみると次のようになった。

カウンタサンプルのソース

Reactと合わせて使う上でのポイントは次みたいな感じ。

  • Applicationコンポーネントでstoreの監視して、変更があったらsetStateして自身に反映する。
  • CounterViewコンポーネントには直接storeは渡さずにstateのみを渡す。
  • ボタンがクリックされた時の処理はモデルの操作をユースケースで包んで置いて直接は行わないようにする。

単純なサンプル過ぎて参考にならないんだけど、とりあえずやりたい感じになったと言えばなった。

ただ、observable-storeはassignが行われた時に保持している状態に変更があるかゴリゴリチェックしているんだけど、ここで頑張らなくてもReactの方に頼ってしまって良いかも...と感じた。

そこで、状態の変更をチェックするのをやめて、assignされたからどこか変わっているかもよ...とオブザーバーに知らせるだけに機能を削ってfreezable-storeというのを作ってみた。

freezable-store

使い方はstoreの生成方法が変わるだけで、他は一緒。

import createFreezableStore from '@ushiboy/freezable-store';

// freezable-storeのインスタンスを生成
const store = createFreezableStore({
  count: 0
});

Reactと合わせてやる分にはこれでも良いかもなーという印象。

今のところの感想

試しに作ったのがカウンタアプリ程度で扱う状態が単純すぎてなんともなーみたいな感じだけど、状態が複雑になってくるとassignのところとかヤバイかなーどうかなーみたいな感じ。とりあえずもうちょっとドッグフーディング

Proxy使ったの初めてだった。

PhinxでマイグレーションしつつEloquentのモデルのテストをする方法

PhinxでDBのマイグレーションしているけど、モデルにはIlluminate\Database\Eloquentを利用していて、 PHPUnit単体テストするときにSQLiteのオンメモリでやりたいなと思ったのでソース見ながら方法探してた。

やり方まとまったのでメモしておく。

こんな感じのモデルを用意して、

こんな感じにテストする。

ポイントはテストのsetUpで、 Illuminate\Database\Capsule\Managerのインスタンスを作って各種設定をしたあとにPDOのインスタンスを取り出して、 それをPhinx\Migration\Managerのインスタンスにセットしてマイグレーションを実行する。

DB設定は一応phinx.ymlから参照するようにしたけど、どうせSQLiteの:memory:しか使わないのであればベタ書きで良いかも。

サンプルコードはこちら。 github.com