メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

1枚岩なMPAプロダクトでWebpackのマルチエントリーをさらにグルーピングしてビルドする

Noita世界の理不尽をこの身をもって体験した末にバウンドルミナスで全てを切り刻んでクリアしました、フロントエンドグループの小宮山です。

以前からこれできたらいいのになぁと思いながら無理そうと諦めていた掲題の事柄を実現できた嬉しさの勢いのままに書き始めています。

状況

1枚岩なMPAプロダクトがどういうものかというと、

  1. ルーティングをRails側で管理するMPA(複数エントリーポイント)
  2. 異なる種別のユーザー向けシステムが複数内包されている

という構成です。

ルーティングについては要するにSPAではなく、ページ毎のhtmlファイルとmain.jsがあるということです。

異なる種別というのは、要するにユーザー向け画面と管理者向け画面が分かれているような状況です。場合によっては3種類、4種類以上の異なるシステムが内包されたりもします。BtoBtoCなサービスだったりする場合ですね。

課題感

このような状況で普通にWebpack設定を組み上げると、もちろん全てのフロントエンドアセットをひっくるめて同じ設定でビルドすることになります。幸いWebpackはマルチエントリーなビルドにも対応しているのでビルド自体に難しさはありません。

一方で、異なる種別のユーザー向けのフロントエンドアセットを一緒くたにビルドするとやや困ったことが起きてきます。

特に気になっていたのが、splitChunksによる複数画面利用モジュールの切り出しに関することです。
SPAと異なりMPAの場合は画面遷移毎にscriptファイルのロードが行われるため、例えばVue.jsなど、ほぼ全画面で利用するようなモジュールは個別のエントリーファイルに入れてしまうとパフォーマンスの低下が懸念されます。
そこでsplitChunksをいい感じに設定していい感じに切り出すわけですが(いい感じの切り出し方は無限に議論があるので今回は触れません)、困ったことにこの切り出しが全ユーザー種別を跨って行われてしまいます。

f:id:robokomy:20210528183805g:plain
従来のビルドイメージ

シンプルな例を挙げると、管理画面でしか使わない重厚なリッチエディタ用モジュールが、splitChunksの対象となることでそれを全く必要としないユーザー向け画面でも取得対象に含まれてしまったような状況です。
node_modules配下を丸ごとvendor.jsに切り出すような設定をしているとあるある状況だと思われます。

妥協案

Webpackビルドをユーザー種別ごと別々に行えば当然ですが上記のような問題は起きません。しかしこれはこれで様々な面倒事が付きまといます。

ルーティングをバックエンドで制御している場合、フロントエンドアセットはmanifest.jsonで管理することが多いと思われます。
Webpackビルドを分けた場合、当然このmanifest.jsonも複数種類出力されることになります。つまりバックエンドも複数のmanifest.jsonを読み分けるような処理をしないといけません。既にやりたくありません。

複数のWebpackビルドを実行しなければいけない点も見逃せません。複数のWebpackビルドを--watchモードで動かしながら開発することに喜びを見出す会の皆様には申し訳ないですが、複数のWebpackビルドを--watchモードで動かしながら開発することに喜びを見出すことは私のような一般フロントエンドエンジニアには不可能でした。

このような様々な不便が付きまとうことから、多少のパフォーマンス悪化には目をつぶってまるごと単一のWebpackビルドで片付けてしまっていたのが現状でした。

光明

1シーズンに1回くらいの頻度でこの課題感と無理感の再発見を繰り返してきていて、今シーズンも再発見に勤しもうと思ったら実はなんとかなりそうなピースが揃っていることに気がつきました。

ピースその1

実はWebpackは複数の設定を配列で持つことができます。

webpack.js.org

リンク先にもある通り、こういう書き方ができます。

module.exports = [
  {
    output: {
      filename: './dist-amd.js',
      libraryTarget: 'amd',
    },
    name: 'amd',
    entry: './app.js',
    mode: 'production',
  },
  {
    output: {
      filename: './dist-commonjs.js',
      libraryTarget: 'commonjs',
    },
    name: 'commonjs',
    entry: './app.js',
    mode: 'production',
  },
];

このビルドを実行すると、2つの設定に基づいたビルドを同時(内部的には順次かもしれません)に走らせてくれます。

サンプルコードのように用途別に生成物を分けたり、ユニバーサルJSなプロダクトでサーバーとクライアント環境それぞれをビルドしたいような場面で活躍しそうです。

ユーザー種別ごとに異なる設定を用意したいという場面も状況は同じなので、きっとそのまま適用できるでしょう。
「複数のWebpackビルドを実行しなければいけない」という問題はこれでなんとかなりそうです。

If you pass a name to --config-name flag, webpack will only build that specific configuration.

なんと名前を付けておくと特定の設定でだけビルドすることもできるようです。admin系画面が不要な開発中はビルド対象から外して高速化するといった使い方もできそうです。

ピースその2

Webpackプラグインとしてmanifest.jsonをいい感じに生成してくれるのがwebpack-assets-manifestで、このプラグインにはmergeというオプションがあります。

github.com

このmergeオプションを有効にするとその名の通り、同名のmanifest.jsonファイルが既に存在している場合、そこに追記する形で新たなmanifest.jsonを生成してくれるようになります。

前回生成したmanifest.jsonを一部引き継ぐなんて絶対面倒な何かを引き起こす厄介な機能じゃないと勘ぐりたくなりますが、実はこのオプションが欲しい状況が存在します。

今回なんとかしたかった状況が正しくそれでした。ユーザー種別ごとに異なる設定でWebpackビルドを行いつつ、最終的なmanifest.jsonは1つに統合することが可能となります。

マルチエントリーグルーピングビルド設定

以上のピースを当てはめるとこのようなWebpack設定を組み上げることが可能です。

app/javascript/packs配下にエントリーファイルが設置されるとして、さらに顧客用画面はcustomers、管理用画面はadminでネームスペースを切っているような例です。

- app
  - javascript
    - packs
      - entry.js
      - customers
        - entry.js
      - admin
        - entry.js

gist.github.com

そして生成されるmanifest.jsonはこのようになります。個別のエントリーファイルは以前同様に生成されつつ、splitChunksしたvendor系ファイルはネームスペース毎の個別で生成してくれています。

{
  /**/
  "entry": "/packs/entry.abc-hash.js",
  "customers/entry": "/packs/customers/entry1.abc-hash.js",
  "admin/entry": "/packs/admin/entry1.abc-hash.js",
  "vendor-root.js": "/packs/vendor-root.abc-hash.js",
  "vendor-customer.js": "/packs/vendor-customers.abc-hash.js",
  "vendor-admin.js": "/packs/vendor-admin.abc-hash.js",
}

f:id:robokomy:20210528183900g:plain
改善後のビルドイメージ

注意点として、splitChunksしたファイルを実際に読み込むhtmlファイルはネームスペース毎に別で用意する必要があります。
とはいえ全ユーザー種別で単一のlayoutファイルを使い回すことは稀で、それぞれ専用のlayoutファイルを用意するケースがほとんどだと思われます。manifest.jsonをhelperで読み分けるような面倒さに比べたらきっと些細なことです。

おわり

異なるユーザー種別向けの設定を別で用意しつつ、ビルド自体は統合されているようにふるまわせたいという贅沢な願いはこうして無事に叶えることができました。 Webpackを素で触れる環境はやはりよいものです。皆様も素敵なWebpackライフを。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html


Amazon ECSで動かすRailsアプリのDockerfileとGitHub Actionsのビルド設定

CTO室SREの@sinsokuです。

Dockerイメージのビルドを高速化するため、試行錯誤して分かった知見などをまとめて紹介します。

AWSのインフラ構成

assetsもECSから配信し、CloudFrontで /assets/packs をキャッシュする構成になっています。

f:id:sinsoku:20210527182858p:plain
Rails on ECS

デプロイ時にassetsが404になる問題

以前の記事に詳細が書かれているため、ここでは問題の紹介だけしておきます。

Rails等のassetsファイルをハッシュ付きで生成し配信するWebアプリケーションの場合、ローリングアップデートを行うと、アップデート時に404エラーが確立で発生してしまいます。

引用: メドピアのECSデプロイ方法の変遷

Dockerfile

実際のDockerfileには業務上のコード、歴史的な残骸などが含まれていたので、綺麗なDockerfileを用意しました。

rails new した直後の最小限のRailsアプリで動作確認していますが、業務のRailsアプリではapkでインストールするパッケージなどは少し変える必要があるかもしれません。

# == base
FROM ruby:3.0.1-alpine3.13 AS base

WORKDIR /app
ENV RAILS_ENV production
ENV BUNDLE_DEPLOYMENT true
ENV BUNDLE_PATH vendor/bundle
ENV BUNDLE_WITHOUT development:test

RUN gem install bundler --no-document --version 2.2.16

# == builder
FROM base AS builder

# Add packages
RUN apk update && apk add --no-cache --update \
      build-base \
      postgresql-dev \
      tzdata \
      git \
      yarn \
      shared-mime-info

# == bundle
FROM builder AS bundle

# Install gems
COPY Gemfile Gemfile.lock .
RUN bundle install \
      && rm -rf $BUNDLE_PATH/ruby/$RUBY_VERSION/cache/*

# == npm
FROM builder AS npm

# Install npm packages
COPY package.json yarn.lock .
RUN yarn install --production --frozen-lockfile \
      && yarn cache clean

# == assets
FROM builder AS assets

COPY . .

COPY --from=bundle /app/vendor/bundle /app/vendor/bundle
COPY --from=npm /app/node_modules node_modules

# Set a dummy value to avoid errors when building docker image.
# refs: https://github.com/rails/rails/issues/32947
RUN SECRET_KEY_BASE=dummy bin/rails assets:precompile \
      && rm -rf tmp/cache/*

# == main
FROM base AS main

# Add packages
RUN apk update && apk add --no-cache --update \
      postgresql-dev \
      tzdata \
      nodejs \
      shared-mime-info

COPY . .

# Copy files from each stages
COPY --from=bundle /app/vendor/bundle /app/vendor/bundle
COPY --from=assets /app/public/assets public/assets
COPY --from=assets /app/public/packs public/packs

ARG SHA
ENV SHA ${SHA}
ENV PORT 3000
EXPOSE 3000

CMD bin/rails server --port $PORT

rails new をMacで実行した場合

MacでBundler 2.2.3以上を使っている場合、Gemfile.lockにPLATFORMSが入ってしまいDockerイメージのビルドに失敗してしまいます。

 > [bundle 2/2] RUN bundle install       && rm -rf vendor/bundle/ruby/3.0.1/cache/*:
#12 0.953 Your bundle only supports platforms ["x86_64-darwin-20"] but your local platform
#12 0.953 is x86_64-linux-musl. Add the current platform to the lockfile with `bundle lock
#12 0.953 --add-platform x86_64-linux-musl` and try again.

ビルドできるようにするため x86_64-linux を追加する必要があります。1

$ bundle lock --add-platform x86_64-linux

マルチステージビルド

このDockerfileには以下のステージが含まれています。

  • base: 全てのステージの親ステージ
  • builder: bundle,npm,assetsの親ステージ
  • bundle: bundle install を実行する
  • npm: yarn install を実行する
  • assets: assets:precompile を実行する
  • main: 本番環境で使うステージ

ステージの依存関係は以下の通りです。

bundle ─┬- assets ─ main
        │
   npm ─┘

bundle stage

bundle install の引数を使うと、mainステージでも bundle config をする必要がありDRYでは無いため、baseステージで環境変数を指定しています。2

ENV BUNDLE_DEPLOYMENT true
ENV BUNDLE_PATH vendor/bundle
ENV BUNDLE_WITHOUT development:test

bundle installを実行します。

RUN bundle install \
      && rm -rf $BUNDLE_PATH/ruby/$RUBY_VERSION/cache/*

npm stage

特筆すべき点は特にないです。 yarn install を実行します。

RUN yarn install --production --frozen-lockfile \
      && yarn cache clean

assets stage

RAILS_ENV=production を設定しているので、 assets:precompile を実行するときに SECRET_KEY_BASE が存在しないというエラーが起きてしまいます。
Railsのissueを参考にSECRET_KEY_BASE=dummy を指定すれば回避できます。3

RUN SECRET_KEY_BASE=dummy bin/rails assets:precompile \
      && rm -rf tmp/cache/*

main stage

rails server の起動に必要なパッケージをインストールしたり、ソースコードや各ステージの成果物をコピーします。

assets:precompile でassetsファイルは結合されているため、 node_modules はサーバ起動時に不要です。

RUN apk update && apk add --no-cache --update \
      postgresql-dev \
      tzdata \
      nodejs \
      shared-mime-info

COPY . .

# Copy files from each stages
COPY --from=bundle /app/vendor/bundle /app/vendor/bundle
COPY --from=assets /app/public/assets public/assets
COPY --from=assets /app/public/packs public/packs

環境変数の定義位置には注意が必要です。 GitのSHAのように毎回変わる値をイメージに埋め込む場合、Dockerfileの下部に記載しておかないとレイヤーキャッシュが効かなくなってしまいます。

ARG SHA
ENV SHA ${SHA}

最後にポート番号とサーバの起動コマンドを設定します。

ENV PORT 3000
EXPOSE 3000

CMD bin/rails server --port $PORT

GitHub Actions

DockerイメージをビルドするGitHub ActionsのYAMLを紹介します。

name: build

on:
  pull_request:
    branches:
      - master
  push:
    paths:
      - '.github/workflows/build.yml'
      - 'Dockerfile'

env:
  ECR_REPOSITORY: example
  HEAD_SHA: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || github.sha }}

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
    - uses: actions/checkout@v2
      with:
        ref: ${{ env.HEAD_SHA }}

    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1

    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v1

    - name: Cache Docker layers
      uses: actions/cache@v2
      with:
        path: /tmp/.buildx-cache
        key: ${{ runner.os }}-buildx-${{ env.HEAD_SHA }}
        restore-keys: |
          ${{ runner.os }}-buildx-

    - name: Build bundle stage
      uses: docker/build-push-action@v2
      with:
        context: .
        target: bundle
        cache-from: type=local,src=/tmp/.buildx-cache/bundle
        cache-to: type=local,dest=/tmp/.buildx-cache-new/bundle

    - name: Build npm stage
      uses: docker/build-push-action@v2
      with:
        context: .
        target: npm
        cache-from: type=local,src=/tmp/.buildx-cache/npm
        cache-to: type=local,dest=/tmp/.buildx-cache-new/npm

    - name: Build and push
      uses: docker/build-push-action@v2
      with:
        context: .
        build-args: |
          SHA=${{ env.HEAD_SHA }}
        tags: |
          ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, env.HEAD_SHA) }}
          ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, 'latest') }}
        push: ${{ github.event_name == 'pull_request' }}
        cache-from: |
          type=local,src=/tmp/.buildx-cache-new/bundle
          type=local,src=/tmp/.buildx-cache-new/npm
        cache-to: type=inline

    # Temp fix
    # https://github.com/docker/build-push-action/issues/252
    # https://github.com/moby/buildkit/issues/1896
    - run: |
        rm -rf /tmp/.buildx-cache
        mv /tmp/.buildx-cache-new /tmp/.buildx-cache

キャッシュ

docker/build-push-actionのCacheのページを参考に、GitHub cacheを使ってbundle,npmのステージをキャッシュします。4

エクスポートするのはbundle, npmのステージになるため、各ステージでもキャッシュを消してイメージサイズを小さく保つようにします。

マージした時のみpush

build-push-action ではレジストリへのプッシュを制御できるので、「git pushではレジストリにプッシュしないで、Actionsでビルド結果を確認してからプルリクを作る」という運用が簡単に実現できます。

- name: Build and push
  uses: docker/build-push-action@v2
  with:
    context: .
    build-args: |
      SHA=${{ env.HEAD_SHA }}
    tags: |
      ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, env.HEAD_SHA) }}
      ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, 'latest') }}
    push: ${{ github.event_name == 'pull_request' }}
    cache-from: |
      type=local,src=/tmp/.buildx-cache-new/bundle
      type=local,src=/tmp/.buildx-cache-new/npm
    cache-to: type=inline

改善の効果

MedPeerのリポジトリで改善したときは他に CodeBuild -> GitHub Actions化なども実施しているため、厳密な計測結果ではありませんが10%程度の高速化を実現できました。

  • 改善前: 🐢10〜11分程度(CodeBuild)
  • 改善後: 🚀8〜9分(Dockerfileの改善 + GitHub Actions化)

まとめ

Dockerイメージのビルドを高速化する知見をブログにまとめてみました。 これらの内容が何か参考になれば幸いです。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html



  1. Bundler 2.2.3+ and deployment of Ruby apps の記事を参考にしました。

  2. bundle configで使える環境変数を調べられる。

  3. https://github.com/rails/rails/issues/32947

  4. GitHubのキャッシュは5GBまでしか使えないため、Registry cacheを使う方が良いケースもあります。

SREチームのセキュリティインシデントゲームデー

CTO室SREの侘美です。最近はM5Stackを嗜んでおります。

ここ半年ほど、MedPeerグループ全体のAWSのセキュリティ改善に力を入れてきました。 その中で、AWS Well-Architectedのセキュリティのベストプラクティスにも記載があるゲームデーを実施したところ、とても学びが多かったので本記事にまとめました。

ゲームデーとは?

Well-Architectedには以下のように記載されています。

ゲームデーを実施する

ゲームデーを実施する: さまざまな脅威について、インシデント対応イベントのシミュレーション (ゲームデー) を実施します。このゲームデーには、主要なスタッフや管理者を参加させてください。
教訓から学ぶ: ゲームデーの実行から得られた教訓は、プロセスを改善するためのフィードバックに含まれている必要があります。

出典: https://wa.aws.amazon.com/wat.question.SEC_10.ja.html

実施したゲームデーの概要

今回行ったゲームデーの概要は以下の通りです。

  • 擬似的に社内のいずれかのサービスでAWS上のインフラに関するセキュリティインシデントを発生させる。
  • どのサービスで発生するかは発生するまで不明とする。
  • セキュリティインシデント対応計画に則りSREチームで対応する。
  • ステージング環境を本番環境とみなしてゲームデーを行う。

今回は初回ということもあり、ゲームデーへの参加はSREのみとし、 私がインシデントを擬似的に発生させ、他のSREメンバー5名で対応にあたるシナリオとしました。

ゲームデーのシナリオ

より実際のセキュリティインシデントを再現するため、次のようなシナリオを事前に作成しました。 SREチームのメンバーへはゲームデー終了後に公開しました。


シナリオ全体の図

f:id:satoshitakumi:20210308135840p:plain
ゲームデーのシナリオ

インシデント

IAMアクセスキーの流出。
ある開発者がバグ調査のためにIAMユーザーを作成し、IAM認証情報を作成した。
Slack上の社外のゲストユーザーが参加しているチャンネルでAWSのアクセスキーIDとシークレットアクセスキーを投稿してしまった。*1
認証情報には、S3やECRやSystem Managerパラメータストアの権限が含まれていた。

インシデントの検知

CTOのTwitterのDMに以下のメッセージがあった体とする。*2

XXX社のYYYです。
御社の<サービス名>というサイトのユーザーのリストと思われる情報がWebに公開されていました。
現在は公開されていないようです。
名前とメールアドレスが載っているので、個人情報流出的に大丈夫ですか??

サイト: https://example.com/path/to/user-list

掲載されている情報(抜粋)

1,テスト太郎,taro.test@example.com
2,テスト次郎,jiro.test@example.com

掲載されている情報は、流出したS3上のCSVファイルの一部となっている。

攻撃者が取得した情報

攻撃者はSlackに投稿されたIAM認証情報を利用して、以下の操作を行った。 (実際に事前に認証情報を利用してこの操作を行う)

  • S3バケットからユーザー情報が記載されたCSVファイルをダウンロード
  • ECRへログインし、サービスで利用しているECS用のRuby on Railsアプリケーションのイメージを取得

本シナリオを利用したゲームデーで確認したい点

  • 初動でIAM認証情報を無効化できるか
  • 以下を調査し適切に報告へ含めることができるか
    • 流出したIAM認証情報
    • 流出した情報
    • ECR上のイメージ流出による影響
    • 流出した経路
  • セキュリティインシデント対応計画の内容は適切か

ゲームデー当日

当日は2時間と時間を区切ってインシデント対応にあたることにしました。

私はシナリオ作成者なので調査は見ているだけで参加しなかったのですが、インシデント検知→作業を分担して各種ログを調査→IAM認証情報特定→他の操作調査とスムーズに対応が進んでいました。

具体的な調査としては、CloudTrail、ALB、ECS、S3アクセスログなどを調査し、流出したIAM認証情報の特定やその認証情報で実行された操作等を特定していきました。
必要なログが取得されて無いといったことは発生しませんでした。

特に初動で流出したデータ内容から、RDS、S3バケット、管理画面などが流出経路である可能性があると判断し、分担して対応にあたった場面は想定以上にスムーズに実行できていたと思います。

最終的な報告では、流出経路を含めほとんど特定でき、その上で流出した情報からサービスの緊急停止を検討すべきか等の議論も行われており、十分対応できた結果となりました。

学び

ゲームデー終了後にSREチームで振り返りを行ったところ、多くの学びや改善点が見つかりました。
すべては記載しきれないので、一部を掲載させていただきます。

  • AWSやGoogle等が説いているインシデント対応のゲームデーの重要さについて、真の意味で理解できた。
  • S3アクセスログ、CloudTrail、Athenaなどを利用して調査を行ったが、習熟度が人それぞれだったため、得意不得意が出た。
    • ログを1箇所に集約し、Athenaを事前に設定しておくことで解消できそう。
  • CloudTrailでのS3オブジェクトレベルのアクセスログ記録が有効になっていないため、S3側で設定しているアクセスログも調査する必要があった。
    • コストとの兼ね合いもあるが、一箇所で検索できると調査がスムーズになる。
  • IAMユーザーの総数が少ないと流出したIAM認証情報の特定は速い。
    • IAMユーザーの棚卸しはやはり大事。
  • (一部のエンジニアにIAMのフル権限を与えているので)IAMユーザー作成検知の仕組みが欲しい。
    • ゲームデー後にCloudTrailのログを利用して通知する仕組みを実装した。
  • 初動の分担等、陣頭指揮は重要。
  • ECRログイン後のアクセスキーが変化するという仕様に気づかず、ECRからイメージをダウンロードしたログを特定できなかった。
  • Ruby on Railsで扱う秘匿情報の管理方法について見直す必要がある。
  • 報告がSlackで散発的に行われたので、最後に報告をまとめる際に苦労した。
    • テンプレートが用意されており、共同編集可能な環境にまとめていくのが良さそう。
  • 次は各サービスのRailsエンジニア等と協力して調査する部分もシミュレートすべき。

AWSのソリューションアーキテクトの方からのアドバイス

AWSのソリューションアーキテクトの方に今回実施したゲームデーの内容を共有したところ、以下のようなコメントをいただきました。
(SAさんに気軽に相談できる環境は本当にありがたい!)

  • 目的・手段が噛み合っているシナリオで良い
  • CloudTrailから影響を絞っていく対応も良かった
  • セキュリティ系インシデントであれば、エンジニア系職種以外の他職種・他チームも巻き込んでシミュレートすると、より実際の対応に近づく
  • セキュリティ系以外には可用性の障害を発生させるシナリオもよくある

次回

上記を踏まえて次回は以下の点を事前に準備・検討した上で再度ゲームデーを夏頃に開催する予定です。

  • 報告書のテンプレートの準備
  • ログ検索の整備
  • SRE以外のエンジニア、非エンジニア職も巻き込んむ

まとめ

セキュリティインシデントのゲームデーを実施することで、実際にやってみないと見えてこない課題などがいくつも見つかりました。
サービスを運用している会社では是非実施することをおすすめします!


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html


*1:GitHubで公開してしまったというよくあるパターンも検討したが、すぐに通知が飛んできて準備中に攻撃がバレそうなので今回は却下しました。

*2:ブログ用に一部内容を修正しています。

メドピアで開催している社内読書会と社内読書分科会について

こんにちは。メドピアのお手伝いをしています @willnetです。花粉が厳しい季節みなさんいかがお過ごしでしょうか。僕は毎年レーザー治療したいな、と思っているのですが気づくともう春になっています。次回こそは…。

さて、メドピアでは毎週水曜日の11時から12時の間に社内読書会を開催しています。これは社内でなるべく多くの人が仕事上で使える知見を獲得することを目的としています。進め方は

  • 予習不要
  • 音読する
  • キリのいいところで止めて感想を話す

という形です。詳しい内容は以前個人のブログで書いたので興味のある人は読んでみてください。

しかし読書会を定期開催するようになってから4年がすぎ、また社内のエンジニアがどんどん増えてくると「読書会をもっと改善したいな」と思うことが増えてきました。

すでに読んでしまった本は題材として扱いづらい

例えばメタプログラミングRuby 第2版は良い本なので、当然この本を題材にした読書会は開催済みです。最近入社したエンジニアにも読んで欲しいのですが、読書会はすでに開催済みのため社内にすでに読了済みの人がたくさんいて、どうしても次の題材選定の際に優先順位が下がってしまいます。

音読しづらい書籍は題材として扱いづらい

日本語訳されていない洋書は、仮にそれが良書でも音読形式で進めづらいので対象外になってしまいます。

ニッチな内容の書籍は題材として扱いづらい

読書会の効果を最大限に高めるために、どうしても最大公約数的な書籍を選びがちです。そのため、RubyやRails全般に関わる書籍、かつジュニア向けの書籍以外を選定するのが難しくなっています。

そこで読書分科会ですよ

現行の読書会は維持しつつ、ここまで出てきた問題を解決するために読書会の分科会(以降分科会)を開催しています。これは通常の読書会とは形式を変えています。

  • 予習前提。予め決めておいた範囲を読んでおく
  • 30分で読んだ感想を話す

これまで取り扱ったお題は次の通りです*1

最大公約数的な題材は通常の読書会、それ以外の題材は分科会とすることで広い範囲をカバーできているように思えます。それぞれ書いたコードを比較し合う、といった読書に限らないことを実施できるのも良い点です。

難点としては、予習前提であることから敷居が高く、脱落率も通常の読書会よりも高いことがあげられます。業務が忙しく予習する時間が取れないことがあるので、なるべく一回の範囲を狭くして、一度サボっても復帰しやすいように工夫しています。

まとめ

メドピア社内で開催している読書会と、そこから派生した分科会について紹介しました。

「どうやってエンジニアのスキルを底上げしていくと良いのか」は難しい問題です。読書会についてももっと良いやり方を模索しています。「うちはこうやっているよ!」という事例があったら教えてもらえると嬉しいです。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

*1:Working with Ruby ThreadsとWorking with TCP Socketsは現時点で購入する場所がなくなってしまっている模様です。悲しい

ECS を利用した検証環境の自動構築 ~運用3年を経て得た知見~

CTO 室 SRE kenzo0107 です。

以前執筆した ECS を利用した検証環境の自動構築について、運用開始から3年の時を経ました。
実運用とその上で頂いた要望を取り入れ変化してきましたので、その経緯を綴ります。

tech.medpeer.co.jp

本稿、議論を重ね改善を進めて頂いたチームメンバーの知見を集めた元気玉ブログとなっております。

前提

社内では、以下の様に呼び分けしています。

  • 本番相当の検証環境を STG 環境
  • 本記事で説明する自動構築される仕組みを持つ環境を QA 環境*1

検証環境の自動構築の目的

開発した機能を開発担当者以外でも簡易的に確認できる様にし、以下を促進します。

  1. ディレクターと開発者の仕様齟齬を減らす
  2. 改善のサイクルを高速化する

当時の検証環境の自動構築の仕組み

当時の検証環境の自動構築の仕組み

大まかな流れ

① ブランチ qa/foo を push
② CircleCI 実行
③ CodePipeline 作成 or 実行
④ Rails イメージビルドし ECR へ push
⑤ TargetGroup, Listner を 既存 STG 環境 LB に追加
⑥ ECS Service 作成 or 更新を実行

ブランチ qa/foo に push すると ECS Service を作成・更新する、 という仕組みです。*2

最新の仕組み

大まかな流れ

① ブランチ qa/foo を push
② CircleCI 実行
③ CodePipeline 作成 or 実行
④ Rails イメージビルドし ECR へ push
⑤ DB 更新
⑥ TargetGroup, Listner を QA 環境用 LB に追加
⑦ ECS Service 作成 or 更新を実行

当時と最新の検証環境の自動構築の仕組みの違い

  • QA 環境用の LB を用意
  • CodeBuild 内で DB 更新
  • 既存 STG DB に QA 環境用 スキーマ作成

この様な仕組みへと変わった歴史を見ていきたいと思います。

構築当初の問題と解決の歴史

問題1: デプロイする度に DB データが初期化される

問題1. デプロイする度に DB データが初期化される

当初、 QA 環境は上図の構成を取っていました。

role (app, admin) 毎に別々にタスク内にDB コンテナを起動し、参照しています。

DB コンテナはデータを永続化していません。
デプロイ毎にデータが初期化されてしまいます 😢

もう一つ問題があります。

app と admin で共通の DB を見ていない為、app で更新したデータを admin で参照できません。

「検証環境」と言っておきながら、role 間 (app と admin 間) のデータの検証はできないんですね(笑)
と、妄想で闇落ち仕掛けましたが、次の方法で解決しました。

解決1: ブランチ毎に role 間で共通の DB スキーマを作る

解決1. ブランチ毎に role 間で共通の DB スキーマを作る

STG 環境 DB に ブランチ qa/foo に紐づくスキーマを作成し参照します。

これにより、ブランチ毎に DB を新規作成することなく、低コスト且つ、 既存 STG DB への影響も少なく目的が達成されました。*3

  • config/database.yml
default: &default
  ...
  url: <%= ENV['DATABASE_URL'] %>

staging:
  <<: *default
-  database: 'hoge_rails_staging'
+  database: <%= ENV['DB_NAME'] || 'hoge_rails_staging' %>
  • タスク定義
{
  "containerDefinitions": [
    {
      "name": "web",
      "environment": [
        {
          "name": "DB_NAME",
          "value": "qa-foo"
        },
      ],
      "secrets": [
        {
          "name": "DATABASE_URL",
          "valueFrom": "xxxxxxx"
        }
      ],
      ...

問題2: ECS Service に oneshot で db:migrate 等実行エラー発生後も処理が停止されない

 ECS Service に oneshot で db:migrate 等実行エラー発生後も処理が停止されない

当初、AWS 公式の ECS 特化ツールである ecs-cli を採用していました。

ecs-cli compose run で ECS Service でタスクを起動し db:migrate, db:seed を oneshot で実行していました。

タスク起動時のリソース不足等で db:migrate が失敗しても処理が停止されない問題がありました。

実行結果のログこそ取れています。

ですが、ログを grep してエラー判定するのは、全エラーパターンを把握しておらず、取りこぼしがある可能性があります。*4

以下の方法で解決しました。

解決2: db:migrate 等 DB 操作は CodeBuild から VPC 接続し実行する

解決2: db:migrate 等 DB 操作は CodeBuild から VPC 接続し実行する

db:migrate 等 DB 操作は ECS Service 上でなく CodeBuild 上で実施する様にしました。

不要となった ecs-cli を削除し、コードも見通しがよくなりました。*5

  • buildspec.yml
  build:
    commands:
      - >-
        docker run --rm
        -e RAILS_ENV=${rails_env}
        -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY
        -e DATABASE_URL=$DATABASE_URL/$DB_NAME
        ${repository_url}:$IMAGE_TAG bin/rails db:prepare db:seed

DB は Private Subnet 上にあるかと思いますが、
その場合、 CodeBuild から VPC 接続し起動する必要があります。

resource "aws_codebuild_project" "web" {
  ...
  # NOTE: rails のイメージビルド後にこの CodeBuild から db:migrate を実行する
  # RDSへ接続できるようにVPC内でCodeBuildを実行する
  vpc_config {
    vpc_id             = aws_vpc.main.id
    subnets            = aws_subnet.codebuild.*.id
    security_group_ids = [aws_security_group.codebuild.id]
  }

CodeBuild を VPC 接続し起動した副産物

CodeBuild で Docker Hub からイメージを pull しています。
通常 CodeBuild は任意の IP で起動します。

その為、既に Docker Hub へ多数のリクエストをした IP を引いてしまうことがあります。

所謂、「CodeBuild IP ガチャ問題」です。

CodeBuild IP ガチャ問題

VPC 接続したことで Nat Gateway で出口 IP を固定し、使い回し IP を利用することがなくなる為、現状の利用頻度では、Docker Hub リクエスト制限を回避できました。*6

CodeBuild IP ガチャ問題 回避

問題3: デプロイ毎に Redis データが初期化される

当初、DB 同様、Redis もタスク内にあり、role 毎に分離した構成になっていました。

主に Redis は Sidekiq のキュー管理で利用しています。

問題3: デプロイ毎に Redis データが初期化される

Redis も DB と同様、 QA 環境毎に role 間で共通のデータを参照できる様、対応が必要です。

解決3: Redis も role 間で同じデータを参照しよう!

解決3-1. Redis DB 番号で分ける

まず Redis の DB 番号で STG と QA 環境で分けます。

Rails に渡す Redis URL は以下の様にします。

  • STG: rediss://stg-hoge.xxxxxx.apne1.cache.amazonaws.com:6379
  • QA: rediss://stg-hoge.xxxxxx.apne1.cache.amazonaws.com:6379/9

redis(s) と s が 1 つ多いのはスペルミスでなく、伝送中 (In-Transit) の暗号化を有効化している為です。*7

QA 環境は DB 番号 9 を指定しています。*8

解決3-1. Redis DB 番号で分ける

ただこれだけでは、 qa/foo, qa/bar の QA 環境は同じ 9 を利用し、干渉します。

解決3-2. gem redis-namespace で QA 環境毎の干渉を回避

gem 'redis-namespace' を採用し、同じ DB 番号 9 内で namespace を指定し QA 環境毎に分離し、データを干渉しない様にしました。

QA 環境の Rails.env = staging です。

Sidekiq 側で QA 環境が起動する staging に namespace の指定をします。

  • config/application.yml
staging:
  redis:
    :url: <%= ENV['REDIS_URL'] %>
    # NOTE: BRANCH = develop もしくは、 qa/xxx が設定される。
    namespace: <%= ENV.fetch('BRANCH', nil)&.gsub('/', '_') %>
  • config/sidekiq.rb
Sidekiq.configure_client do |config|
  config.redis = Settings.redis.to_h
end

参考: https://github.com/mperham/sidekiq/blob/4338695727d0bf16c9bf90d4170c55232bfc0957/lib/sidekiq/redis_connection.rb#L53-L69

問題4: QA 環境のタスクが増え過ぎて DB が Too many connetions エラー

QA環境がカジュアルに利用される様になり、起動するタスクが増え、 DB で Too many connections エラーが多発する様になりました。

問題4: QA 環境のタスクが増え過ぎて DB が Too many connetions エラー

解決4: QA 環境のタスクのみ RAILS_MAX_THREADS を抑える

QA 環境のタスクのみ RAILS_MAX_THREADS を抑え、DB コネクション数を抑えることで暫定対応としました。

極力 DB スペックアップによるコスト増を避けたい意図です。

何かと相乗せ相乗せでコスト削減してますが、 弊社も希望と予算の合意が取れればスペックアップするんですよ 💸 *9

問題5: QA 環境にはどこまでリソースが必要か?

メドピアの最新のデファクトとなりつつあるアーキテクチャ

メドピアの最新のデファクトとなりつつあるアーキテクチャは上記図の通りです。

CloudFront を前段に配置しています。 レスポンスの高速化と AWS Shield の恩恵を受ける為です。

QA 環境はどこまで検証の為のリソースを用意する必要があるでしょうか?

RDS, ElastiCache は STG 環境に相載せしてきましたが、 CloudFront, ElasticsearchService, S3 等も用意すべきでしょうか?

解決5: 機能の検証に必要なリソースのみ用意する

CloudFront は ALB の様にポートによるルーティングができません。*10
その為、QA 環境で CloudFront を利用するにはブランチ qa/xxx 毎に構築する必要があります。*11

ですが、
CloudFront を毎回構築するのは時間が掛かります。
直ちに検証できる状態にならないデメリットがあります。

その為、
「CloudFront の機能を前提とした機能の検証は QA 環境ではしない」
という合意の元、QA 環境では CloudFront を採用しませんでした。

CloudFront の機能を前提とした機能の検証は QA 環境ではしない

本番相当の検証は STG 環境で実施します。

まとめ

元々、必要になった経緯は、以下の依頼からでした。 検証環境の自動構築を作るきっかけとなった依頼

依頼を言葉通りに受けていたら terraform で環境をコピーしたものを用意して終わっていたかもしれません。

ですが、依頼者の言葉を翻訳すると
「任意のブランチのコードが動く STG 環境相当の環境作って!」
でした。*12

この翻訳に掛けた時間がとても貴重だったと、運用を3年経過して思います。

そして、何より記事を書かねば!と至ったのは、このアーキテクチャが今まさに更なるアップデートを遂げている最中だからです。

その内容がブログに執筆されることを期待して筆を置こうと思います。

ご清聴ありがとうございました。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html`

*1:Quality Assurance の手助けになれば、と当時発足した QA チームとかけて QA としました。「QA 環境って何ですか?」と新入社員に聞かれることも多く、誤解招く命名だったなと思う。ごめんなさい。名前大事。運用を経て用途として「気軽に試せる場所」という意味合いが強くなってきたので sandbox に改名することを検討中

*2:ブランチ qa/xxx 削除時に Webhook → Lambda 関数で作成した QA 環境のリソースを削除しています。

*3:ブランチ qa/xxx 削除時に Webhook → Lambda 関数で作成した DB スキーマ削除しています。

*4:capistrano でラップしていた影響もあるかもしれません。未検証です。すいません。

*5:ecs-cli は CodeBuild にプリインストールされていない為、インストールするコードを書く必要があります。

*6:あくまで暫定対応ではありますが、現状の利用頻度では効果覿面でした。

*7:Redis クライアントに hiredis を利用している場合、SSL サポートが安定してない為、注意が必要です。 https://github.com/redis/hiredis-rb/issues/58

*8:"Q"A 環境だから 9 がしっくりきたのもありますが、 0-8 は STG 用、 9-16 は QA 環境用に使えるかな、という今後の運用の予備をとっておきたい意図から 9 にしています。

*9:DB の max_connection を調整したりと極力スペックアップを回避しつつ、どうしてもというときは勿論インスタンスクラスをアップします。

*10:QA 環境のエンドポイントの分離にポートを採用したのは、非エンジニアでもアクセスを容易にしたい為です。ヘッダー情報や Cookie 等でルーティングする方法が非エンジニアには難易度が高く回避した経緯があります。

*11:Lambda@Edge でゴニョゴニョすればできそう?!とも思いましたが、アイディア浮かばず

*12:ブランチ駆動にしたのはこの依頼のまま受け取ってます。PR 単位でなくブランチ駆動にした方が構築される QA 環境の数が少なくて済み、コストが抑えられる為です💸

Ruby3.0でのパターンマッチ機能の変更点

こんにちは、メドピアのサーバサイドエンジニアの草分です。

2020年12月25日、ついに待望のRuby3.0がリリースされましたね。

以前、Ruby2.7で発表されたパターンマッチについての記事を執筆したのですが、Ruby3.0になりいくつか追加/変更が入っています。 この記事ではそれらの変更点を確認していきます。

「Rubyのパターンマッチとは何ぞや?」という方は是非前回の記事も合わせてご覧ください。 tech.medpeer.co.jp

Ruby3.0を使うには

rbenvなど、主要なサードパーティツールが既にRuby3.0に対応しています。
それらを使ってインストールするのが簡単でしょう。

# rbenv
rbenv install 3.0.0
# rvm
rvm install ruby-3.0.0

Windowsの方は RubyInstaller for Windows などをお使いください。

rubyを実行しRUBY_VERSIONが3以降になっていれば準備完了です。

irb(main):001:0> RUBY_VERSION
=> "3.0.0"

1行パターンマッチ(experimental)

# version 3.0
{a: 0, b: 1} => {a:}
p a # => 0
# version 2.7
{a: 0, b: 1} in {a:}
p a # => 0

Ruby2.7ではパターンマッチのinを用いて右代入のようなことができていましたが、Ruby3.0からは=>を用いるように再設計されました。

下記のように分割代入をさせることもできます。

attrs = { name: 'メドピア太郎', email: 'med@example.com' }

attrs => { name:, email: }
p name # => "メドピア太郎"
p email # => "med@example.com"

一方、in は true/false を返すようになりました。 条件判定として使えそうですね。

{ a: 0, b: 1 } in { a: } # => true
{ a: 0, b: 1 } in { c: } # => false

Find Pattern(experimental)

case ["a", 1, "b", "c", 2, "d", "e", "f", 3]
in [*pre, String => x, String => y, *post]
  p pre  #=> ["a", 1]
  p x    #=> "b"
  p y    #=> "c"
  p post #=> [2, "d", "e", "f", 3]
end

* を指定することで、複数要素から要素数に関わらずマッチする部分のみ抽出できるようになるパターンも追加されました。

下記のように、Hashに対して条件指定と属性の抽出を同時に行う。といったこともできるようになります。

case [{name: "sato", age: 18}, {name: "tanaka", age: 15}, {name: "suzuki", age: 17}]
in [*, {name: "tanaka", age: age}, *]
  p age # => 15
end

case/inが実験的(experimental)な機能ではなくなった

irb(main):001:0> RUBY_VERSION
=> "3.0.0"
irb(main):002:1* case 0
irb(main):003:1* in a
irb(main):004:1*   puts a #=> 0
irb(main):005:0> end
0
=> nil

パターンマッチを利用してもexperimentalであることの警告が発生しなくなりました。

ただし、3.0で追加された1行パターンマッチやFind Patternについてはexperimentalのままです。要注意。

irb(main):001:0> {b: 0, c: 1} => {b:}
(irb):1: warning: One-line pattern matching is experimental, and the behavior may change in future versions of Ruby!

 

irb(main):001:1* case ["a", 1, "b", "c", 2, "d", "e", "f", 3]
irb(main):002:1*   in [*pre, String => x, String => y, *post]
irb(main):003:1*   p x    #=> "b"
irb(main):004:0> end
(irb):2: warning: Find pattern is experimental, and the behavior may change in future versions of Ruby!
"b"

おわりに

Ruby3.0のリリースノートからパターンマッチに関する部分を抜粋して紹介いたしました。参考になれば幸いです。

パターンマッチ自体の概要については前回の記事にまとめております。 こちらも是非ご覧ください。


是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

フロントエンドのコードを書いている時に考えていること - まず状態から始めよ編

椅子に甘えないと心に決めて最近はスタンディングメインで仕事してます小宮山です。

実は私はペアプロ・モブプロ好き人間です。なぜ好きかというと、単にワイワイコードを書けるというのもありますが、何よりもそのときに考えていることをリアルタイムに共有できるからです。

メドピアのCTO室フロントエンドグループ(最近正式にグループ化されました)は、CTO室という何やら凄そうな名前の部署に所属している通り、メドピア社内のフロントエンド開発を幅広く支援するという役割を持っています。その一環としてペアプロ歓迎ムードを漂わせているわけです。

そして先日久しぶりにペアプロに誘われたのでほいほい承って参戦してみて、やはりペアプロという場はいいなと感じてこんな記事を書いています。

で、何をテーマにするかというとタイトルの通りです。おそらく近頃のフロントエンド開発に慣れた方なら特に意識しなくともそういう考えをしているのではと思うので、それほど目新しく斬新な考え方というわけではありません。

ただ世の中の開発者全員が全員、近頃のフロントエンド開発に慣れているわけでもないはずで、特に普段はバックエンドメインで片手間にフロントエンドも触るけどよく分からんという方もいるのではと思います。私は近頃のフロントエンド開発に慣れた側に立てているだろうということを最上の謙虚な心を持って認めると、残念ながら慣れていない方が感じている、何が分からないか分からないという気持ちを汲むのはなかなか難しかったりします。

なので役に立つのかは分からないけれども、もしかしたら誰かの役に立つかもしれないということで、どんなことを考えながらコードを書いているを紹介してみようというのが今回の趣旨です。

実装タスク

概要

  • 既にテーブル形式でデータ一覧を表示する機能がある
  • そのテーブルにて、行を選択できるようにしたい
  • 複数行選択を可能にしたい
  • 表示されている行全ての選択を切り替える全選択機能も欲しい

要するにこれをこうしたいというタスクです。選択して何をしたいんだということは気にせずいきます。

f:id:robokomy:20201008124711p:plain
Before 👉 After

1stステップ - 機能の境界を意識する

選択して何をしたいんだということは気にせずいきます。

さらっと書きましたが実はこれも考え方として重要かもしれないということで1stステップです。例えば大元の要件が「まとめて選択して削除したい」という場合、「まとめて選択して削除する」機能と考えるのではなく、「まとめて選択する」機能と「削除する」機能を分けて考えた方がいいケースがほとんどです。

せっかくなのでなぜかを説明すると、今後新たに「まとめて選択して移動したい」という要望が来ても対応しやすいからです。そしてフロントエンドという領域にDBはないので、「削除する」「移動する」という機能はそれぞれAPIに処理を委ねることになります。ではフロントエンド側に何が残るのかというと、「まとめて選択する」という機能と、「選択したものをAPIに投げる」という機能です。APIに投げるのは多少のインタフェース調整が必要かもしれませんが、実質ただの関数実行です。つまり「まとめて選択する」という機能さえ作れてしまえば、「まとめて選択して○○したい」という要望の大半は叶ったようなものです。

以上を踏まえて、選択して何をしたいんだということは気にせず、「まとめて選択する」という機能をこれから実装しようと一目散に考えます。

2ndステップ - 状態から考え始める

選択して何をしたいかは気にしませんが、「選択して何かをする」が控えていることを忘れてはいけません。もし本当に「まとめて選択する」という機能だけが欲しいのであれば、テーブルに<input type="checkbox />"をまぶした時点でもう実装は完了ということになります。

ここでふと思ったのですが、もしかしたらjQuery時代であればこれは正しかったのかもしれません。なぜなら「選択する」という機能はチェックボックスを設置するだけで実際に満たされるからです。そしてチェックボックスのDOMをそれぞれ取得して選択状態を調べてその後の「何かをする」に引き渡せば終了です。「全選択」機能はもう少し追加の実装が必要になるものの、まぁ適当にイベントハンドラを設定して適当な処理を適当に書けばおそらくなんとかなるでしょう。

当然ですが現在は(少なくとも観測範囲内では)jQuery時代ではないのでこういう考え方はしません。

「選択して何かをする」が控えている

スタート地点はここです。処理的にはゴール地点ですが設計的にはスタート地点です。選択した後に、その選択したという状態を必要とする後続処理が控えています。つまり、「選択したという状態」が欲しいわけです。

「選択したという状態」がある。ここが全ての基点となります。

何度でも言いますが「選択したという状態」が基点です。「選択する」という機能は二の次です。とりあえず機能を作り出すのではなく、真っ先に状態を定義します。

3rdステップ - 状態を形にする

基点となる状態が見えてきたのでそろそろ手を動かします。先に状態以外も含めた全体像を設計しきってしまうというのもありですが、ペアプロ想定ということで手を動かして抽象度を下げていきます。

「選択したという状態」を表現します。今回選択対象となるデータはそれぞれユニークキーidを持っていると想定します。特段難しく考えるまでもなく、選択したデータのidArrayObjectで持てば良さそうです。ぱっと見シンプルな気がするのでArray でいきましょう。どちらでも大した違いはないのでお好みで。

let selectedIds:number[] = []

主役が完成しました。結局のところ、後続の処理に回すために興味がある情報はこれだけです。

型が付いている方が視認性が良いと思うのでTSで書きます。JS原理主義者のみなさんごめんなさい、私はTSに屈しました。

ちなみですがパフォーマンスをシビアに求めるならObject方式がオススメです。

let selectedIds:{ [key: string]: boolean } = {}

そこまでのシビアさが求められるケースはあまりないのでお好みでどうぞ。あるいはAPIがどちらを採用しているかで判断するのがいいかもしれません。

4thステップ - 状態を変化させる

4rdと書きたい気持ちを抑えて4thステップです。

主役となる状態が作れたので、次はその状態に対してどんな操作をしたいか考えます。今回の操作はシンプルで、「選択する」と「選択を外す」です。2種類の操作ということですが、どちらの操作を行いたいかは状況によって変わります。状況とは、上で定義した状態のことです。

改めて説明するまでもなく、「選択済」なら「選択を外す」、そうでなければ「選択する」が実現したいことです。

function toggleSelected(selectedIds:number[], id: number): number[] {
  if (TODO 選択済) {
    return selectedIds.filter(_id => _id !== id)
  } else {
    return [...selectedIds, id]
  }
}

特定フレームワークに依存しない考え方がテーマなので、なるべく簡素に書いていきます。

「選択済」かを判定して「選択を切り替える」関数を作りました。ただどうやら関数を完成させるには、「選択済」かの判定が必要なようです。ではそれはどうすれば得られるのか。もちろん、主役である「選択したという状態」から求めることができます。

function isSelected(selectedIds:number[], id: number): boolean {
  return selectedIds.includes(id)
}

「選択を切り替える」関数も完成させます。

function toggleSelected(selectedIds:number[], id: number): number[] {
  if (isSelected(selectedIds, id)) {
    return selectedIds.filter(_id => _id !== id)
  } else {
    return [...selectedIds, id]
  }
}

この時点で、「選択されたという状態」、「選択済」かの判定、「選択を切り替える」という操作が揃いました。なんだかもう選択機能が作れたような気がしてきませんか?気のせいではないです。個別の選択機能はもうこれで完成です。まだUIがないだけです。

5thステップ - 状態をUIと繋げる

選択するための状態とその状態を切り替えるための関数は既に作成しました。あとはそられをUIと連携させれば完成なのですが、この連携というのがなかなか厄介です。というのはかつての話で、今はそれほど厄介ではありません。

なぜ厄介ではなくなったかというと、それは昨今のフロントエンドに関わる方なら耳にタコができるくらい聞かされたであろう宣言的なUI構築を売りにした各種フレームワークのおかげです。宣言的というのが重要です。だからこそ先ほども状態ありきで処理を作っていました。そして状態に関する処理を既に作ってしまったので、あとはフレームワークのお作法に沿ってUIを組み立てていくだけです。細かく気を使う箇所は都度あるにせよ、状態さえ定まってしまえば、宣言的なUIの構築は最早シンプルな作業です。

というわけでここから先は単に今まで作ったものをフレームワークと合わせて組み立てていくだけなので、特筆すべきことはあまりありません。

というわけで終わりますと投げっ放しにする度胸もないので続きます。せっかくなので今回はみなさん大好きSvelteでいきます。

svelte.dev

先に完成形を張ってしまいます。公式サイトにさくっと触れる砂場を用意してくれているので、そこでも遊んでみてください。

<script>
  const dataList = [
    { id: 1, name: 'メドピア1号' },
    { id: 2, name: 'メドピア2号' },
  ]
  let selectedIds = []
  $: isAllSelected = selectedIds.length === dataList.length
  
  function toggleAllSelected() {
    if (isAllSelected) {
      selectedIds = []
    } else {
      selectedIds = dataList.map(data => data.id)
    }
  }
</script>

<p>
  選択中: {selectedIds.join(', ')}
</p>

<table>
  <thead>
    <tr>
      <th><input type="checkbox" checked={isAllSelected} on:change={toggleAllSelected} /></th>
      <th>ID</th>
      <th>Name</th>
    </tr>
  </thead>
  <tbody>
    {#each dataList as { id, name }}
      <tr>
        <td><input type="checkbox" bind:group={selectedIds} value={id} /></td>
        <td>{id}</td>
        <td>{name}</td>
      </tr>
    {/each}
  </tbody>
</table>

悲しいお知らせがあります。まずはパッと見てもらえれば分かるように、さすがに砂場だとTypeScriptは非対応でした。心の目で型の補完をお願いいたします。

さらに悲しいお知らせが続きまして、なんと先ほど意気揚々と作成したisSelectedtoggleSelectedという関数は出番がありませんでした。

<input type="checkbox" bind:group={selectedIds} value={id} />

なんとこれだけでSvelteがチェックボックスと配列の双方向バイディングを完成させてくれました。選択判定やトグル処理は自作する必要すらなかったです。

せっかく書いたコードが不要になりましたが、無駄な時間を過ごしたと嘆く必要は全くありません。むしろその不要なコードを順を追って作ったからこそ、フレームワークが提供してくれる機能に対してより深い理解が得られるというわけです。たとえcommitログに何も残らなくともすべてを血肉に変えていきましょう。そしてペアプロ・モブプロに慣れてくるとcommitの量なんて無意味です。より大事なのはコード品質です。

もしSvelteにこんな便利な機能がなかった場合にどういう展開にしようとしてたかを一応解説しておきます。

<input type="checkbox" checked=選択されている? onchange=選択をトグルする />

正しいHTMLになっていませんがイメージとしてはこのような形です。選択状態の判定とトグル処理は既に用意したので、あとはこの形を各々のフレームワークでどう表現するか探すだけです。ここから先はフレームワークの表面的な作法や記法の問題です。

まとめ

かなり単純化してしまいましたが、コンポーネントの実装に取り組むときの流れは大概このような流れです。早く見た目を作りたい欲はぐっと抑え、とにかく真っ先に状態を設計して形に落とします。状態さえ定めてしまえば後はUI実装を思う存分楽しむだけです。

レビューだけではどうしても完成した後のコードを見るというケースが多く、こうした実装の考え方はなかなか共有が難しいものです。一方でペア・モブプロをしてみると、まさにこういった実装者の思考と共にコードを追っていくことができます。

ペア・モブプロが好きな理由もこのあたりだったりします。他人の作業風景を覗き見て取り入れることで自らの作業効率を改善できるという副次作用もあったりします。普段そういった機会があまりない方々も、ここまで読んでいただけた縁と思ってぜひとも周囲の方を誘ってペア・モブプロを試してみてください。

そしてなんと、メドピアではエンジニアを絶賛募集中です。集合知というキーワードの元にチーム開発を楽しみたい、そんな方は是非ともお声がけください。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html`