メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックリーディングカンパニーのエンジニアブログです。PHPからRubyへ絶賛移行中!継続的にアウトプットを出し続けられるようにみんなでがんばりまっす!

Rubyバージョンアップで見つけたバグとハマりどころ

こんにちは、最近ruby-vipsに惚れ込み始めたエンジニアの宮原です🐕

先日、医師専用コミュニティサイト「MedPeer」で使用されているRubyをVersion 2.6.5にアップデートしました🎊

f:id:nyagato_00_miya:20191024163222p:plain

今回は、Rubyアップデートを行った際にハマった箇所について紹介と解説をしてみたいと思います。 また、類似の内容で発表もさせていただいておりますので、合わせてご一読いただければと思います。

今回紹介するハマりどころは、ActiveSupport::DurationのバグとSidekiqの安全な再起動についてです。

🐛Durationのバグ

最新のdevelopを取り込み、Rubyアップデートのブランチで作業しているとReceived 'killed' signalというエラーが発生し、CIのRSpecが途中で終了してしまう事象に遭遇しました。

f:id:nyagato_00_miya:20191024182229p:plain
3並列目でメモリを食いつぶし移行のテストが失敗している

調査を進めると、あるテストで異常な処理時間がかかっていることがわかりました。これによりCircleCIのメモリが枯渇し、残りのテストも軒並み失敗していることが判明しました。 メモリを枯渇させるほどの異常な処理の原因も調査を進めていくと、Range#stepActiveSupport::Durationを渡す箇所でパフォーマンスが著しく低下しており、こちらが原因であることを突き止めました。

どの程度、処理速度に差があるのかを確認するため、以下のコードで検証します。

require 'active_support'
require 'active_support/core_ext'

start = Time.now
(0..300).step(15.seconds).to_a
pp "time: #{Time.now - start}"

処理速度は下記の通りで、Ruby 2.6系を使うと圧倒的に処理速度が悪化していることがわかります。

Ruby 2.5.3 Ruby 2.6.3
time: 2.1e-05 time: 11.550944

こちらの事象が発生する組み合わせは、下記の通りです。

ソフトウェア バージョン
Ruby 2.6.3, 2.6.4, 2.6.5
Rails 5.2.3
ActiveSupport 5.2.3

github.com ※issueも上がってました。

🔍なぜ計算量が指数関数的に増加してしまったのか

Range#stepActiveSupport::Durationの挙動が怪しそうなので、検証していきます。 下記のようなベンチマークコードを利用して、処理速度を計測していきましょう。

require 'active_support'
require 'active_support/core_ext'
require 'benchmark'

current = 0
step = 15.seconds

loop do
  puts current

  time = Benchmark.realtime do
    current = step.coerce(current).sum
  ene

  p "processing time:#{time}"
end

計測結果は下記の通りです。ご覧の通り、Ruby 2.6系ではステップ数が増加するごとに、著しく処理時間が増加していることがわかります。

f:id:nyagato_00_miya:20191031105425p:plain
Ruby 2.5とRuby 2.6による処理速度の比較

Integer#stepRange#stepは、Ruby 2.6からEnumerator::ArithmeticSequenceを返すように修正されました。 (Ruby 2.5までは、Enumeratorを返してました。)

Ruby 2.6の変更では、Enumerator::ArithmeticSequenceすなわち等差数列が返り値となることを期待しています。 しかし、期待するデータ構造が仮に(duration 15)としていたところに、(duration (duration (duration (duration (duration 15))))のようなネストが深いデータ構造で渡ってしまう場合、再帰処理も相まって処理時間が指数関数的に増加していました。

🔧修正するには

Ruby 2.6系とRails 5.2系の組み合わせで、当該事象を回避するにはIntegerとして値を渡せば大丈夫です。

(0..300).step(15.seconds.to_i).to_a

🚦Sidekiq Jobの安全な再起動

RubyやRailsのアップデートを実施する場合は、各サーバーをHard Restartする必要が生じます。 しかし、Jobサーバーなどを思い切ってHard Restartしてしまうと、実行中のJobが正しく完了しない場合や2重に実行されてしまうなどの問題が起きる可能性があります。 このため、Jobサーバーを安全に再起動する必要が生じました。

🔧安全な再起動手順について

「MedPeer」ではSidekiq Enterpriseを利用していますので、こちらの安全な再起動について解説します。

以下の手順でSidekiqを安全に再起動させていきます。

  1. Jobを新規実行しないようにする
  2. 実行中のJobが終わるまで待つ
  3. Sidekiqのプロセスを停止
  4. einhornのプロセスからSidekiqのプロセスを起動

まず、現在実行中のJob数を確認します。

takashi-miyahara@stg-hogehoge-batch-a01:~$ ps -ef | grep sidekiq
medpeer  10921     1  0 10月09 ?      00:00:00 einhorn: bundle exec sidekiq --config config/sidekiq.yml --environment staging
medpeer  12864 10921  2 15:38 ?        00:00:18 sidekiq 5.2.7 medpeer [3 of 10 busy] leader
#     ↑こちらがSidekiqのプロセス
takashi+ 13506 13421  0 15:50 pts/0    00:00:00 grep --color=auto sidekiq

どうやら3つのJobが実行中のようですね。

次に、Sidekiqのプロセスへ-TSTP(v5.0.0 未満は USR1)シグナルを送りkillします。

takashi-miyahara@stg-hogehoge-batch-a01:~$sudo kill -TSTP 12864

Sidekiqのプロセスをkill後に、プロセスを確認するとstoppingとなり徐々に実行中のJobが減っていきます。

takashi-miyahara@stg-hogehoge-batch-a01:~$ ps -ef | grep sidekiq
medpeer  10921     1  0 10月09 ?      00:00:00 einhorn: bundle exec sidekiq --config config/sidekiq.yml --environment staging
medpeer  12864 10921  2 15:38 ?        00:00:18 sidekiq 5.2.7 medpeer [1 of 10 busy] stopping
takashi+ 13506 13421  0 15:50 pts/0    00:00:00 grep --color=auto sidekiq

その後、すべてのJobが実行済みになることを確認します。

takashi-miyahara@stg-hogehoge-batch-a01:~$ ps -ef | grep sidekiq
medpeer  10921     1  0 10月09 ?      00:00:00 einhorn: bundle exec sidekiq --config config/sidekiq.yml --environment staging
medpeer  12864 10921  2 15:38 ?        00:00:18 sidekiq 5.2.7 medpeer [0 of 10 busy] stopping leader
takashi+ 13506 13421  0 15:50 pts/0    00:00:00 grep --color=auto sidekiq

これで、後続のJobが実行されない状態になりました。

次に、TERMシグナルを送りプロセスをkillします。

takashi-miyahara@stg-hogehoge-batch-a01:~$ sudo kill -TERM 12864

しばらくするとeinhornのプロセスが、自動的にSidekiqのプロセスを起動してくれます。 これは、einhornのPIDを親プロセスに持つ子プロセス(Sidekiqのプロセス)がkillされると、einhornが新しいプロセスを立ち上げます。

では、新しく起動したプロセスの様子を確認してみましょう。

takashi-miyahara@stg-hogehoge-batch-a01:~$ ps -ef | grep sidekiq
medpeer  10921     1  0 10月09 ?      00:00:00 einhorn: bundle exec sidekiq --config config/sidekiq.yml --environment staging
medpeer  13535 10921 92 15:55 ?        00:00:02 /var/www/medpeer/shared/bundle/ruby/2.5.0/bin/sidekiq --config config/sidekiq.yml --environment staging
takashi+ 13537 13421  0 15:55 pts/0    00:00:00 grep --color=auto sidekiq

まだ、Ruby 2.5が使われているようですね。どうやらeinhornの自動再起動では新しいRubyを読み込んでくれないようです。 こんな時は、Sidekiqを明示的に再起動してあげましょう。

takashi-miyahara@stg-hogehoge-batch-a01:~$ sudo service sidekiq restart
takashi-miyahara@stg-hogehoge-batch-a01:~$ 
takashi-miyahara@stg-hogehoge-batch-a01:~$ ps -ef | grep sidekiq
medpeer  10921     1  0 10月09 ?      00:00:00 einhorn: bundle exec sidekiq --config config/sidekiq.yml --environment staging
medpeer  13535 10921 92 15:55 ?        00:00:02 /var/www/medpeer/shared/bundle/ruby/2.6.0/bin/sidekiq --config config/sidekiq.yml --environment staging
takashi+ 13537 13421  0 15:55 pts/0    00:00:00 grep --color=auto sidekiq
takashi-miyahara@stg-hogehoge-batch-a01:~$ 
takashi-miyahara@stg-hogehoge-batch-a01:~$ sudo service sidekiq status
● sidekiq.service - sidekiq
   Loaded: loaded (/lib/systemd/system/sidekiq.service; enabled; vendor preset: enabled)
   Active: active (running) since 水 2019-10-09 22:00:22 JST; 17h ago
  Process: 12782 ExecReload=/usr/local/rbenv/shims/bundle exec einhornsh --execute upgrade (code=exited, status=0/SUCCESS)
 Main PID: 10921 (bundle)
    Tasks: 22
   Memory: 242.6M
      CPU: 31min 34.652s
   CGroup: /system.slice/sidekiq.service
           ├─10921 einhorn: bundle exec sidekiq --config config/sidekiq.yml --environment staging
           └─13535 sidekiq 5.2.7 medpeer [0 of 10 busy] leader

無事、Ruby 2.6を使って起動していることがわかります。 これで、長時間Jobの実行を止めることなくSidekiqを安全に再起動することができました。

当該手順を実行する前に、Jobのスケジュールを確認し再起動できそうな時間帯などを確認しておくと良いでしょう。

これから

前々回のRubyアップデートから約9ヶ月を経て、最新のRubyで「MedPeer」が動作するようになりました。 「MedPeer」の開発メンバーも増えてきましたので、もう少し早い周期でRubyのアップデートができそうですね。

今後も、RubyやRailsのアップデートに関する内容を発信していければと思います!


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


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

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

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

■開発環境はこちら

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

半年間の開発環境の改善を振り返る

こんにちは、メドピアCTO室 SREの侘美(たくみ)です。
普段はRails/Vue.js/terraform/Lambdaなどを書いています。
趣味は飼い猫と遊ぶことで、生傷が絶えません。

入社してから約半年間、Railsのプロジェクトで実装をしつつ、合間に開発環境の改善をいろいろとやってきました。けっこうな分量となったので、紹介したいと思います。

なお、本記事で扱う開発環境とは下記2つを指すこととします。

  • ソースコードの修正/テストの実行/静的解析の実行環境
  • サービスを起動し、ブラウザでデバッグする環境

特徴

主な改善対象である、「MedPeer」サービスの特徴をご紹介します。

  • Ruby on Rails製
  • 社内では最も巨大なRailsプロジェクト
  • モデル数693
  • 認証サービス、旧サービス(PHP製)と連携している
  • 開発環境はDocker for Macを利用
    • コンテナ数は旧システム、認証システムを入れて32個
  • ソースコードはdocker-syncを使ってローカルとコンテナ内で同期
  • RSpecやrubocopの実行もコンテナ内で実行

システム構成としては、ざっくり下図のようになります。

f:id:satoshitakumi:20191028124949p:plain
サービス構成

課題

私が入社した当時、MedPeerサービスの開発環境には下記のような課題がありました。

  • 電池消費が激しい
  • 起動に時間がかかる
  • メモリ消費が激しい
  • 起動に失敗するときがある
  • docker-syncが不安定
    • ファイルが同期されない
    • CPUが高負荷になる
  • 依存システムのコンテナが多い
  • Linux対応していないため、一部テスト環境のメンテナンスが放置され気味

改善内容の紹介

それでは、改善内容を順に紹介していきます。

電池消費

開発環境を立ち上げていると電池をモリモリ消費する状態でした。
開発環境のコンテナ群を立ち上げっぱなしで、ミーティングに出ると、だいたい、2時間程度で電池が切れてしまうくらいでした。

docker stats でCPU使用率が高いコンテナを絞り込み、top コマンドでプロセスのCPU使用率を確認したところ、SpringのプロセスがCPUを常に利用していることを特定できました。

SpringはRails 4.1から標準で付属するようになったapplication loaderです。 常にバックグラウンドで実行しつづけることで、rails consoleやRSpecの実行等、ロードを伴うコマンドの実行を高速にしてくれます。 しかし、MedPeerサービスではロード対象となるファイル数が多いためか、常時それなりのCPUを消費していました。

Springには、DISABLE_SPRINGという環境変数を指定することで、無効にする機能があります。 この環境変数を設定して一律で無効にすることもできるのですが、電池消費よりもスピードを優先するエンジニアも当然いますので、任意に設定できるように対応しました。 medpeer.jpの開発環境にはdirenvが導入されているので、これを利用し、各エンジニアのローカルで指定した環境変数に応じて、コンテナ内にDISABLE_SPRINGを引き渡すように設定しています。

起動が不安定対策

メモリ消費が多いこともあり、開発環境を落とすことが多いのですが、起動しようとすると、Railsの起動や依存コンテナの起動に失敗することが多々ありました。

原因は様々あったのですが、大半は下記のように起動順序によるものでした。

  • gem、npmの依存ライブラリのインストール量で起動順序が変わっている
  • DBやElasticsearch、fluentd等依存コンテナの起動より先にこれらに接続するコンテナが立ち上がり、エラーとなる

もちろん、docker-composeによるlinksdepends_onを指定し、コンテナの起動順序は担保しているのですが、これらではコンテナ内のプロセスがreadyになったことまでは担保してくれません。

そこで、コンテナ内のプロセスが応答するまでwaitしてくれるufoscout/docker-compose-waitを利用することにしました。 似たツールはいくつもあるのですが、複数のコンテナ/ポートへの疎通チェックができる点で、docker-compose-waitを採用しました。

READMEに書いてあるように取得したスクリプトを /wait にマウントし、環境変数と起動時のcommandを設定することで、指定したコンテナの指定したポートに疎通することをチェックしてから、任意のコマンドを実行することが可能です。

サンプルのdocker-compose.ymlは以下のようになります。

---
version: '3'
services:
  web:
    image: ${ECR_NAME}/app:1.0
    environment:
      # チェックする対象を環境変数に定義する
      WAIT_HOSTS: fluentd:24224,elasticsearch:9200
    # /waitが終了したら、実行したいコマンドを実行する
    command: /bin/sh -lc "/wait && ./bin/setup"
    volumes:
      - ./bin/wait:/wait
      # その他ソースコードのマウント設定
  fluentd:
    # fluentdコンテナの設定
  elasticsearch:
    # elasticsearchコンテナの設定

この設定により、依存モジュールのインストール状況等で、各コンテナの起動スピードが多少変化しても、コンテナの起動順序を担保し、起動に失敗し辛い設定することができました。

脱docker-sync

docker-syncが不安定であることもエンジニア内で問題視されていました。

  • 異常にCPUを消費するときがある(暴走状態!)
  • ホスト-コンテナ間の同期が遅いときがあり、ソースコードの変更が反映されていないときがある(突然の死!)

(上記の問題はdocker-syncが利用しているunison起因であることまでは確認しています)

docker-syncはネイティブなDocker環境ではないMac等の環境において、ホスト-コンテナ間のファイルの参照を高速化されるために開発されたツールです。

そもそも、Docker for MacはVM上でLinuxを起動し、そのLinux上のDockerを利用する形なので、ホスト-コンテナ間のファイルの参照(同期)が遅いものとして有名です。

ということで、全員Linuxで開発すれば解決です!!

docker volumeのcacheオプションを利用することで、docker-syncをやめて安定/高速なソースコード同期を実現しようとチャレンジしてみました。

dockerでは、volumeマウントのdelegatedオプションを利用することで、ホスト-コンテナ間でのファイルの参照の遅延を許容し、高速な参照を実現することが可能です。

# before
  volumes:
    # sync-volumeへの./appのマウントはdocker-sync.ymlファイルで定義されている
    - sync-volume:/app
# after
  volumes:
    - ./app:/app:delegated

結論から言うと、脱docker-syncはできませんでした。

開発環境を立ち上げ、複数のページの表示速度を計測した結果、いずれも約2.3倍程度表示スピードが劣化し、ページの表示に2~3秒かかるようになってしまいました。 この数値はしばしば見かける「docker-syncで約2倍程度高速になる」という記述とも合致しており、確からしい結果となりました。

ある程度巨大でファイル数のあるプロジェクトにおいて、Docker for Macで開発を行う場合、docker-syncが最も高速であるという知見を得ました。 一方で規模の小さいアプリケーションであれば、docker volumeのcacheオプションだけでも開発環境のページ描画はそこまで遅くならないので、安定性を重視してdocker-syncの利用は不要だと思います。

また、docker-syncを利用していないプロジェクトにdelegatedオプションを導入したところ、jestの実行が数倍高速になるといった副次効果を得ることもできました。

依存モジュールをコンテナ内に閉じ込める

こちらは、docker-syncを導入していないプロジェクトに導入した設定です。

前述したように、Docker for Macのvolumeマウントはとても遅いので、ホスト側のファイルを参照する量が増えるほど、コンテナ内でのRailsの挙動は遅くなります。

上記の課題を解決するため、下記のような対策を採りました。
現状、vendor/bundle配下のファイルがホスト側で必要になるシーンが特にないため、vendor/bundle以下のファイルはコンテナ内のみに存在するように構成を変更します。 また、コンテナを破棄/再作成した際に、vendor/bundle以下のファイルが削除されてしまうと、再度bundle installするのに時間がかかってしまうため、docker volumeを使い、コンテナのライフサイクルとは別に永続化しています。

具体的なdocker-compose.ymlファイルは下記のようになります。

# before
services:
  web:
    volumes:
      - ./app:/app

# after
services:
  web:
    volumes:
      - ./app:/app
      - bundle:/app/vendor/bundle
      - node_modules:/app/node_modules
volumes:
  bundle: {}
  node_modules: {}

図で示すと、下図のようになります。

f:id:satoshitakumi:20191028132546p:plain
before

f:id:satoshitakumi:20191028132622p:plain
after

この構成により、コンテナ内から参照するホスト側のファイル数を減らし、Railsの動作を高速化することができます。

Linux対応

MedPeerサービスの一部のテスト環境は、Ubuntu上に構築した開発環境で動作しています。

過去にdocker-sync導入後、Ubuntu上の環境は別ブランチで構築する構成となっていたため、メンテナンスされず放置される傾向となっていました。 この問題を解決するため、同じコードでdocker-syncを利用したMac OSでも、docker-syncを導入していないLinux OSでも開発環境が構築できるように修正しました。

具体的な方法としては、docker-composeのoverride機能を利用します。 Mac OS上では、docker-compose.ymlを読み込み、docker-sync startdocker-compose upコマンドで環境を立ち上げます。 Linux OS上では、環境変数にCOMPOSE_FILE=docker-compose.yml:linux.ymlを設定し、docker-compose.ymlに加え、linux.ymlを読み込み、設定を一部上書きします。その設定でdocker-compose upコマンドで環境を立ち上げます。

Linux OSの場合にdocker-compose.ymlを上書きするlinux.ymlには、docker-syncに関するvolumeの設定を上書きし、通常のdockerによるvolume mountの仕組みでソースコードをマウントするように設定します。

---
# docker-compose.yml
version: '3'
services:
  web:
    image: ruby:alpine
    volumes:
      - sync-volume:/app
volumes:
  sync-volume:
    external: true
---
# linux.yml
version: '3'
services:
  web:
    volumes:
      - ./app:/app

さらに、Makefile中でOSに応じてCOMPOSE_FILEの設定を変更するように設定してあるため、同じmakeコマンドを実行することで、Mac OSでも、Linux OSでも環境が立ち上がるように構築されています。

認証機能の有無の切り替え

われわれのプロジェクトの中には既存の認証サービスと連携するものがいくつかあります。
開発環境でも本番環境と同等の認証の仕組みを動かそうとすると、認証に関係するコンテナだけで、13個ものコンテナを追加で起動する必要があります。

これでは明らかにローカルマシンのリソース消費が増えてしまうため、開発環境では認証機能をダミーに変更し、これら13個のコンテナを削除したい気持ちになります。 しかし、一方で認証機能の検証を行いたいシーンもあります。

そこで、新しいいくつかのプロジェクトでは開発環境で認証機能の有無を切り替えられるようにし、認証機能無しで開発環境を立ち上げた場合は、余計なコンテナが起動しないように設定しています。

こちらに関しても、docker-composeのoverride機能で実現しています。 認証機能を利用しない構成でdocker-compose.ymlファイルを用意し、これに認証サービスを追加するためのauth.ymlファイルを用意します。

これらのdocker-composeで利用するファイルは、USE_AUTH環境変数をdirenvを利用して設定し、Makefile中でCOMPOSE_FILE変数に設定することで制御しています。 また、Rails内部の実装でも認証をダミーの実装に切り替える必要があるため、USE_AUTH環境変数をRailsのコンテナに渡し、実装が切り替わるようにしています。

認証機能を利用しない構成の場合は、ベースとなるdocker-compose.ymlのみで構築されます。

---
# docker-compose.yml
version: '3'
services:
  rails:
    # rails用コンテナの設定
  mysql:
    # mysql用コンテナの設定

f:id:satoshitakumi:20191028134355p:plain
認証機能を利用しない構成

認証機能を利用する場合は、Makefileにて環境変数COMPOSE_FILE=docker-compose.yml:auth.ymlを設定することで、認証機能を追加します。 COMPOSE_FILEに指定したことで、下記のauth.ymldocker-compose.ymlがoverrideされ、下の図のような構成でコンテナが立ち上がります。

---
# auth.yml
version: '3'
services:
  rails:
    environment:
      USE_AUTH:
  auth:
    # 認証サービスのコンテナの設定
  # その他認証系のコンテナが合計13個

f:id:satoshitakumi:20191028134420p:plain
認証機能を利用する構成

このような切り替え機構を導入することで、ほとんどの開発シーンでは認証機能を省略し、省リソースでの開発環境を実現することができました。

不要なコンテナの停止

「MedPeer」サービスの開発環境には、一部の機能の動作を検証すためのコンテナや、モバイル版含めてすべてのサービスが動作するように下記のコンテナも含まれています。
つまり、デフォルトはmaximumな構成となっています。

起動しているコンテナの中には、下記のようなものも含まれています。

  • メールの内容を確認するためのmailcatcherコンテナ
  • S3へのファイルアップロードを検証するためのminioコンテナ
  • モバイル版等、一部の機能で利用するAPI用コンテナ

こういったコンテナはすべての開発者の環境で必要となるわけではないので、不要なコンテナを停止させることで、開発環境のリソース消費量を削減しておきたいです。

一から構築する場合は、何度か紹介したdocker-composeのoverrideを利用し、デフォルトをminimumな構成とし、特定の開発時に必要なコンテナは別ファイルで定義し、環境変数で切り替えるのが良いでしょう。

しかし、今回はすでにデフォルトがmaximumな構成となっているため、docker-composeのoverride機能を利用し、不要なコンテナは起動後即終了するように設定することで、コンテナの起動数を減らす方向としました。 こちらも最小構成で十分な開発者は、direnvを利用し環境変数にCOMPOSE_FILEを設定することで、構成の切り替えをできるようにしています。

---
# docker-compose.yml
version: '3'
services:
  rails:
    # rails用コンテナ
  mailcatcher:
    # メールをwebUIで確認できるmailcatcherコンテナ
  minio:
    # AWS S3互換のminioコンテナ
---
# docker-compose.minimum.yml
version: '3'
services:
  mailcatcher:
    entrypoint: ['echo', 'Service disabled']
  minio:
    entrypoint: ['echo', 'Service disabled']

個人ごとのカスタマイズを可能に

上記を応用することで、自分のみのコンテナを追加したり、逆に特定のコンテナを起動しないようにするといったカスタマイズも可能になっています。

docker-compose.custom.ymlのようなファイルを作成し、任意の設定を追加します。 また、.git/info/excludeに設定しコミットから除外します。 環境変数のCOMPOSE_FILEを設定し、作成したdocker-compose.custom.ymlを読み込むようにすることで切り替えを実現できます。

まとめ

今までの半年間で行ってきた開発環境の改善を振り返ってみました。
単純なものからちょっとテクニカルなものまで、いろいろやったなあ、という感想です。

今後も改善を続けてイケてるモダンな開発環境を実現していきたいと思います!


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


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

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

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

■開発環境はこちら

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

f:id:satoshitakumi:20191029102404p:plain

滞りなくサービスをクローズするために必要なこと

メドピアエンジニアの難波です。

医師専用コミュニティサイト「MedPeer」では、今年の8月にMedPeer Journalというサービスのクローズを行いました。今回の記事ではその時に行った作業の紹介をしたいと思います。

サービスの新規開発に関する記事というものは世の中にたくさんあれど、大規模なサービスにおける一部機能(サービス)の終了に関する経験や知見は中々オープンにされにくいものです。しかしサービスを滞りなく素早く終了させることは新しいサービスを作るリソースの確保という観点でも大事なことであり、今回の記事が将来の参考になればと思い1つの事例としてここに認めます。

MedPeer Journalについて

MedPeer Journal(以下Journal)とはPubMed(Wikipedia)という医学を中心とする生命科学の文献情報を収集したオンラインデータベースへの検索エンジンを利用して、MedPeer会員が世界中の論文に対して議論したりコメントしたりすることができるサービスです。2018年の夏にスタートしたのですが一年ほどの運用を経て検討した結果、様々な理由により2019年8月にサービスを終了することとなりました。

事業的な観点による反省や改善点などは色々とあるものの今回はそれは置いておいて、本記事ではMedPeerのような内部で様々なサービスが運営されているWebサービスにおける、一部サービスの終了とそれに伴う開発的なフローについてまとめます。

クローズの計画づくり

一言でサービスを終了するといっても、サービスを終了するために必要な実装というものが存在します。提供しているのが単一のサービスでありそれを終了するなら極論サーバを落としたりドメインの向き先をS3に置いた「サービス終了のお知らせ」という一枚のHTMLにすることも可能ですが、JournalのようにMedPeerというサービスの1コンテンツとして運用しているものはそうもいきません。

よってまずサービスの終了に必要な作業をまとめ、見積もりを行います。その見積もりを見て終了しない場合に毎月かかるコストやリスク、終了にかかる実装コスト、終了に伴うユーザへの影響などを総合的に鑑みて判断が行われます。この部分はサービスの性質によって内容が大きく異なる部分かと思いますが、Journalでは終了に必要な作業として大まかに以下のようなタスクを挙げました。

  • 事前に必要なこと
    • ユーザへの告知
    • 関係者への連絡
    • データの削除に関する方針の決定
  • 終了日に必要なこと
    • ルーティングの削除
    • 一部URLにアクセスした場合のリダイレクト処理
  • 終了後に必要なこと
    • ソースコードの削除
    • 関連サービスの停止
    • データの移行や削除
    • 移行データの管理画面作成

実装作業

事前に必要なこと

開発が必要なものとしてまずはユーザへの告知です。Journalのトップ画面にお知らせという形で表示しました。

f:id:kyoheinamba:20190930134228p:plain

またJournalでは一部の論文に関してお手伝いいただいている医師の先生に論文解説を書いていただいておりました。これらを含め他のサービスで活かせる情報も多く、具体的に必要な移行作業などを検討して方針を決めました。

終了日に必要なこと

終了日に最低限必要なことはユーザが正常にサービスにアクセスできなくなることです。そのためにまずRailsのroutes.rbから該当するルーティングを削除しました。また一部URLについてはトップ画面へリダイレクトするという処理をNginxの設定ファイルに追記しました。

ここで大変だったのは社内の別のページからJournalへリンクしているかどうかの調査です。MedPeer内の別ページからのリンク(ヘルプページなど)、更にはコーポレートサイトにJournalの紹介などのリンクがないかをチェックする必要もありました。基本的には社内の各サービス担当者にヒアリングを行い、各種リポジトリ内でgrepすることで抜け漏れを探す作業になります。

終了後に必要なこと

ユーザがサービスにアクセスできなくなってもソースコードはまだ残っており、それを消さなければRailsや各種gemfileのアップデート時の負債になり続けます。Journalにおいては上記で書いたように一部データについては移行を行う予定だったため、ViewとController、テストについてはほとんど全てを、Modelについては一部を残して削除しました。今回はRubyの世界で移行作業を行うことにしたためこういう方針にしましたが、別の方針としてデータベースの当該テーブルをダンプしてS3等に保存、その後新規に作成したテーブルにSQLでデータを流し込むといった方針もあったと思います。

またJournalでは検索機能を提供しており、そのためにElasticsearch on Elastic Cloudを使用しておりました。こちらについては他のサービスも使用しているため全面的にストップということにはなりませんでしたが、データの削除、インスタンスタイプの変更などを行いました。

また他サービスに移行して使用することになったデータを管理するための管理画面の作成などを行いました。

注意しておくべき点

実際に起きたヒヤリハットなのですが、大規模な機能改修が頻繁に行われているリポジトリでは様々なブランチで編集が行われるファイルがあります。Railsでは routes.rbschema.rb が該当するものでしょう。

そういう時に注意してレビューを行わないと、クローズ時に消したはずのコードが復活することがあります。クローズ担当者は現在アクティブに動いているPull Requestについても一通り確認しておきましょう。

まとめ

今回のJournalクローズでは終了後のトラブルといったこともあまりなく、滞りなく作業を行うことができました。

サービスをクローズすることは残念なことですが、それによって生まれた開発的な正の変化としては以下のようなものがあります。

  • bundle update 時の確認コスト減少
  • Rubyアップデート時の確認コスト減少
  • 不要になったgemの削除
  • CIの完了までにかかる時間の減少

終了しないに越したことはありませんが、終了するとなったら後顧の憂いを残さぬように予定を決めて速やかに削除しましょう。


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


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

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

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

■開発環境はこちら

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

最近のheadless chromeを利用したファイルダウンロードのテスト方法について

こんにちは。メドピアのRuby(Rails)化をお手伝いしている@willnetです。最近大阪Ruby会議02に妻子を連れて参加したのですが、👶が行き帰りの新幹線に合わせて寝てくれたおかげで大変スムーズに移動できました。

さて、以前poltergeistからheadless chromeへ移行する時に気をつけることというブログエントリを書きました。

その中で、ファイルダウンロードのテストをheadless chromeで実行するための設定について書いています。しかし、この設定では最近のchrome(chromedriver)では動かなくなってしましました。このエントリでは最新のやり方について紹介します。

これまでの設定例

以前のブログエントリに掲載したコードを一部再掲します。

Capybara.register_driver :headless_chrome do |app|
  driver = Capybara::Selenium::Driver.new(
    app,
    browser: :chrome,
    desired_capabilities: Selenium::WebDriver::Remote::Capabilities.chrome(
      login_prefs: { browser: 'ALL' },
      chrome_options: {
        args: %w(headless disable-gpu window-size=1900,1200 lang=ja no-sandbox disable-dev-shm-usage),
      }
    )
  )
  bridge = driver.browser.send(:bridge) # ここからがファイルダウンロード用の設定
  path = "session/#{bridge.session_id}/chromium/send_command"
  bridge.http.call(
    :post, path,
    cmd: 'Page.setDownloadBehavior',
    params: {
      behavior: 'allow',
      downloadPath: DownloadHelper::PATH.to_s,
    }
  )
  driver
end

Capybara.javascript_driver = :headless_chrome

これまで、headless chromeでのファイルダウンロード機能はデフォルトで無効だったので、有効にするために上記のようなコードを書く必要がありました。

しかし最近のchromedriver(v77以降)の仕様変更により、上記のコードは動かなくなってしまいます。

新しいchromedriverでは、上記のような設定をせずともデフォルトでファイルのダウンロードが有効になっています。このとき、デフォルトではカレントディレクトリがダウンロード先になります。上記の設定がこのデフォルトの挙動に置き換わってしまうため、DownloadHelper::PATHにファイルがダウンロードされることを期待しているすべてのダウンロード関連のテストが失敗するようになります。

(追記)この現象はv77時点ではlinux版のchromedriverでのみ起こるようです。macの場合はこれまでの書き方か、後述しているSelenium::WebDriver::Chrome::Driver#download_path=を利用した書き方でのみテストが通り、次の解決策で紹介した書き方だとテストが失敗します(ややこしいですね…)。

解決策

次のように修正すると、ダウンロード先をDownloadHelper::PATHで設定したディレクトリに変更できます。

Capybara.register_driver :headless_chrome do |app|
  browser_options = Selenium::WebDriver::Chrome::Options.new
  browser_options.args << '--headless'
  browser_options.args << '--disable-gpu'
  browser_options.args << '--no-sandbox'
  browser_options.args << '--disable-dev-shm-usage'
  browser_options.args << '--lang=ja'
  browser_options.args << '--window-size=1920,1200'
  # この行がメインの変更
  browser_options.add_preference(:download, default_directory: DownloadHelper::PATH.to_s)
  Capybara::Selenium::Driver.new(
    app, browser: :chrome, options: browser_options
  )
end

Capybara.javascript_driver = :headless_chrome

以前の設定と比べて、selenium-webdriverに対するオプションの渡し方が新しいものに変わっています。が、そこは本筋ではないので置いておいて、browser_options.add_preference(:download, default_directory: DownloadHelper::PATH.to_s)がメインの変更点です。これによりchromedriverでのダウンロードディレクトリの設定を変更することができます。

ちなみにselenium-webdriverには3.13.0以降でSelenium::WebDriver::Chrome::Driver#download_path= メソッドが生えているため、v77未満のchromeを利用している場合は、bridge = driver.browser.send(:bridge) ...のようにせずとも次のように書くことができるようになっています(内部でやっていることは一緒です)。こちらのほうが簡潔で良い感じですね。

# 略
  driver = Capybara::Selenium::Driver.new(
    app, browser: :chrome, options: browser_options
  )
  driver.browser.download_path = DownloadHelper::PATH.to_s
  driver
end
# 略

お手持ちのchromeのバージョンに合わせてご利用ください。

謝辞

このエントリで紹介した内容について、@jnchitoさんに情報提供いただきました。ありがとうございました(\( ⁰⊖⁰)/)


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


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

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

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

■開発環境はこちら

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

v-onから辿るVueの細道

みなさんこんにちは、フロントエンドピラティストの小宮山です。
しばらく休養していたランニングを再開し、ハムストリングスの探求に勤しんでいるのが近況です。

v-onの不思議

templateでのv-onの書き方にはいくつかバリエーションがあります。
なんとなく書いてもVueがいい感じに解釈してくれてしまうので普段はあまり気にしていないんですが、よくよく考えてみると不思議な挙動をしているようにもみえてきます。

最もオーソドックスなのはこれではないでしょうか。

<input type="text" :value="value" @input="input" />

methodsとして定義しておいた関数をそのままイベントハンドラとしてtemplateに埋め込む形です。

methods: {
  input() { ... }
}

こう書いても動作は同じです。

<input type="text" :value="value" @input="input()" />

見慣れたtemplate構文だと思いますが、@input="~~"~~に書かれた処理はJavaScriptの構文としては全くの別物です。一方は関数の参照であり、もう一方は関数を実行した戻り値であるはずです。
なのにVueのイベントハンドラとしては両者の動作は同じです。

なんででしょう?不思議に思いますよね、思ってください。思ってくれたことにしてこのまま話を続けていきます。

v-onの書き方バリエーション

まずはv-onの書き方を種類分けしていきます。これは特に公式にそういう区分があるわけではなく、勝手に分類してみただけです。

methods埋込み型

<input type="text" :value="value" @input="input" />

オーソドックスな書き方。

methods実行型

<input type="text" :value="value" @input="input('hoge')" />

引数を指定したいときに使う書き方。

③ 関数埋め込み型

<input type="text" :value="value" @input="(val) => $emit('input', val)" />

親コンポーネントにイベントを渡して行きたいときによく使う書き方。

④ 式埋め込み型

<input type="text" :value="value" @input="value = value + 'a'" />

methodsにするのが面倒なときに使う書き方。

おそらくこの4種類が代表的な書き方ではないでしょうか。
例示のために微妙に処理内容を変えてしまいましたが、どの書き方をしてもイベントハンドラとしては期待通りの動作をしてくれるのはみなさん御存知の通りです。

v-onの書き方によるパフォーマンスの違い

4種類それぞれがパフォーマンスに与える影響が気になるところです。

実は先日社内フロントエンド勉強会の場でReact入門が実施され、hook周りの仕組み、特にuseCallbackによるイベントハンドラ最適化の努力にとても興味を惹かれました。useCallbackを使わない場合と比べてコードが冗長になるのは間違いないのに、それを受け入れてまで最適化に不断の努力を行うReactの姿勢には鬼気迫るものがあります。

Reactがここまでやっているんだから、じゃあVueはどうなのよというのは当然の疑問です。実はこの疑問から始まってv-on周りの挙動やコードを調べて回った結果がこの記事だったりします。

本題に戻り、v-onの書き方によるパフォーマンスを検証していきたいと思います。

パフォーマンスの差はありません。

結論がでました、この記事の本題は以上です。
すみません終わりません。ちゃんと根拠を提示する義務を果たします。

v-onのコードを読む

実は当初はパフォーマンスの差があるだろうと決めつけ、ブラウザの開発者ツールでメモリ利用量とにらめっこしたりしていました。
ただどう頑張っても、有意に差があるだろうと見て取れるような状況は発生していませんでした。

そしてv-onの書き方ごとのパフォーマンスグラフを貼り付けて、「こんなにパフォーマンスに差が出る!」「こういう書き方をするVue使いは素人」「これからはこの書き方一択」というマウンティングをかましていくという目論見は見事に崩れ去りました。

f:id:robokomy:20190912140827p:plain
変わり映えしない!

マウンティングは失敗でしたが、なぜ差がでないのかという新たな疑問を抱いてしまうのがエンジニアの性です。差がないと性がかかってしまったなと気にし始めるのもきっとエンジニアの性です。

パフォーマンス差がでない理由をパフォーマンス計測から見つけるのは難しいので、Vueの実装を探索していきます。

以下ではv2.6.10タグのコードベースで紹介していきます。

github.com

はい、見るべきコードはここです。

これが何かというと、@input="~~"~~に書かれた文字列をイベントハンドラとして実行できる関数に変換している部分の実装です。

まずはこの分岐に、先に上げたv-on記法の①と③が突入します。細かい説明は省きますが、handler.valueに上述の~~に書かれた文字列がそのまま入っています。
そのままreturnしているので、イベントハンドラとして~~に書かれた関数がそのまま実行されます。①と③は関数の参照なのでそのまま実行するだけというわけです。

ちなみに$emitしたときはこんな感じでイベントハンドラを実行しています。使う側には魔法に見えても、Vueの内部実装はもちろん魔法ではなく地続きの実装です。

さらに脇道にそれると、thisを付けなくてもtemplate内でmethodsを使えたりするのはここでwith(this)とされているからでした。改めて実装を探ってみると、なるほどなぁという発見がたくさんあります。

(へー便利そうと思っても軽い気持ちでアプリコードにwithを使うのは絶対にやめましょう。)

再び本道に戻ります。

残りの②と④はこちらの分岐で、②だとisFunctionInvocationtrueになり、④だとfalseです。returnされるかという違いはありますが、function($event){ .. }でラップすることで、どちらも~~に書かれた処理がそのまま実行されるのが特徴です。

function($event){ .. }というラップを利用し、イベント引数を$eventという変数として受け取るなんて小技もあったりします。実は今回始めて知りましたが、ちゃんとドキュメントのこのあたりにも書いてあります。

ドキュメントだとネイティブのDOMイベント用っぽいですが、非ネイティブなコンポーネントについても同じイベントハンドラの文字列パースがされているので共通で使うことができます。
イベントをそのまま親コンポーネントに渡したいときのショートハンドとして使えなくもないかもしれません。

<input type="text" :value="value" @input="$emit('input', $event)" />

ただし受け取れるのは第一引数のみです。複数の引数が欲しい場合は素直に(a, b) => hoge(a, b)という形にするか、argumentsを使う必要があります。

@input="hoge(...arguments)"という書き方をするとbabel変換とVueコンパイラの相性が悪いのかエラーになってしまうんですが、この書き方ができる詳しい条件ご存じの方いたら教えて下さい。

v-onの書き方によるパフォーマンス結論

Vueの実装をざっと眺めてもうお気づきだと思いますが、今回紹介した4種類のv-on記法はいずれも同じような加工とパース処理を経て実際に実行されるイベントハンドラへと変換されます。
加工とパースに若干の差異はあるものの、その後の処理に差はありません。

そして加工とパース処理は基本的にビルド時に行ってしまいますので、ランタイムにおいてパフォーマンスに差がでることもないというわけです。function($event){ .. }によるラップで関数呼び出しのネストが増えるのは確かです。とはいえさすがにその差を気にする必要に迫られる環境は皆無だと思います。

今回はv-onについての調査でしたが、render関数を作って直接onの設定をした場合は状況が別物です。この場合はrenderが評価される度にそこに書かれた処理が実行されるので、ReactがuseCallbackで最適化を目指したのと同じような状況が生まれます。

Vueのtemplateを使っている限りv-onのパフォーマンスを気にする必要はない、というよりしても何もできないが正しいですが、renderを使う場面に遭遇したらパフォーマンスについても気にしておくことをおすすめします。

まとめ

v-onを使うときに気にするべきはパフォーマンスではなく、可読性。

もう締めに入っているのに唐突に可読性という主張を始めてしまいました。
templateに複雑な処理を書かないようmethodsに切り出していくことはもちろん重要です。 重要ですが、その理由はパフォーマンス観点から来るものではないというのはこれまで見てきた通りです。

では何の観点かというと、やはり可読性ではないでしょうか。
ただフラグをトグルだけの処理や、ただ親に$emitするだけの処理までmethodsに切り出すべきか判断に迷ったときは是非ともパフォーマンスではなく可読性に重きを置いていきましょう。


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


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

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

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

■開発環境はこちら

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

IT x 社会貢献 ~レアジョブ・メドピア 開発事例公開~

こんにちは。サーバーサイドサウナーの川井田(@tamamushi_2)です

先日、レアジョブさんと勉強会を開催し、同期の櫻井(@shibadog39)と登壇してきたので、資料と一言コメントをお送りしますm(_ _)m

medpeer.connpass.com

 

資料・コメント

川井田

私は、Sidekiq Enterpriseの導入事例として、メール配信JOBの改善を例にだして、Enterprise + Pro で使える機能紹介をメインに、発表しました!

機能紹介に力を入れすぎて、何を言いたいのかわからない資料になっていますが、この資料をみてSidekiq Enterprise導入したよって声が聞こえてきたら、嬉しいです。

speakerdeck.com

櫻井

こんにちは。メドピア筋トレ部、幽霊部員の櫻井です。

自分は、WebAPI開発におけるスキーマ駆動開発をテーマにLT登壇させていただきました。 また、今回のイベントのテーマがIT×社会貢献ということで、自分が担当しているサービス「kakari」についても紹介しています。 kakari.medpeer.jp

さて、本題のスキーマ駆動開発ですが、

・API定義のドキュメントをメンテしていくのしんどすぎ
・WebAPI開発の効率を上げていきたい

と思ったことが一度でもある方には、ぴったりの内容となっていますのでぜひ目を通してみてください。

speakerdeck.com

今回のLTでは盛り込めませんでしたが、スキーマ駆動開発を目指してみて「困ったこと、大変だったこと」についても機会があったらいつかお話できればと思っています。

イベントの様子

クラフトビールを用意させて頂き、イベント開始と同時にプシュ!っとして、懇親会も盛り上がりました!

f:id:degwinthegreat:20190902200528j:plainf:id:degwinthegreat:20190901120030j:plainf:id:degwinthegreat:20190901120004j:plain

おわりに

引き続きメドピアでは、開発で得た知見を公開するイベントを予定しております。

メドピアの開発に興味のある方、お酒の飲みたい方は、ぜひ参加して下さい!!


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


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

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

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

■開発環境はこちら

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

松本の地でSass/SCSSの邪悪なアンパサンドを撲滅するために立ち上がった

メドピアCTO室フロントエンドエンジニアの小宮山です、よろしくおねがいします。
趣味はボルダリングとヨガとピラティスです、よろしくおねがいします。

6月某日、長野県松本市の地にて開催されたメドピア開発合宿で取り組んだことについて紹介していきたいと思います。

アンパサンドへのウラミツラミ

タイトルにも挙げたとおり、今回立ち向かったのはSass/SCSSのアンパサンド(&)です。 メドピアのリポジトリはほぼSCSSで統一されているので、この記事ではSCSSの記法ベースでコード例を載せていきます。

アンパサンド記法は、BEMライクなセレクタを書くときによく利用されるのではと思います。
例えばこのようなものです。

.header {
  &__foo {
    color: green;
  }
  &--bar {
    color: blue;
  }
}

アンパサンドを使い、冗長な記述を限りなく減らしたスマートな書き方ですね。書いてるときはDRYなライブ感と共にスタイルを書き進めることができます。

しかしこの書き方には、セレクタの検索可能性が損なわれるという非常に大きな欠点があります。

検索妨害

上述のようなスタイル指定がされたコードに手を入れる場面を想定してみます。変更を加えようとしているファイルはhtmlとjs側です。

html

<div class="header__foo">
  foo
</div>

htmlにはheader__fooというclassを持ったタグがあり、jQueryを使ってセレクタからそのタグを取得して何かの操作をしているとします。

js

$('.header__foo').hide()

何の変哲もない古き良きjQueryコードです。

続いてこの処理をVueで置き換えてみます。細かいところは省略しますが、こんな感じになるのではないでしょうか。

<template>

<div class="header__foo" v-show="show">
  foo
</div>

<script>

Vue.extend({
  data() {
    return { show: false }
  }
})

さくっと置き換えることができ、JavaScript側はもうタグに付けておいたclassを必要としなくなりました。
不要なものは消せるときに消してしまうのが正義です。早速header__fooというclassをタグから消してすっきりさせてしまいましょう。

<div v-show="show">
  foo
</div>

と迂闊に消してしまう人はさすがにいないと信じたいです。JavaScriptから参照していなくても、CSSからclassを参照している可能性は大いに残っています。

このclassに対するCSSを書いた本人なら、このclassがまだ必要であることを覚えているかもしれません。しかし人の記憶は頼りになりませんし、このclassを消していいか迷っているのは何も知らない別人かもしれません。消したいclassがまだどこかで使われているかの判断に迷ったときは、grepするに限ります

どんな結末が待っているかはもう想像がついたかと思います。アンパサンドによる文字列結合で指定されているこのセレクタは、タグに付いているclass名でgrepしても拾われません。

.header {
  &__foo {
    color: green;
  }
}

そして使われていないことを確信し、コード改善と思ってclassを消した開発者には、スタイル崩れという悲劇が待ち受けています。

このようにアンパサンドによってセレクタが文字列結合されていると、タグのidやclassがどこで使われているのか見つける手段が大幅に制限されてしまいます。 地道に探せば見つけられるかもしれませんが、その労力と、不要なセレクタを減らす地道なコード改善が釣り合うのかは微妙なところです。
そして結局は触らぬ神に祟りなし、必要なのかも分からないidやclassは消えることなく未来に向かって増え続けていきます。

purgecss

検索妨害と仕組みは同じですが、アンパサンドによるセレクタ結合はpurgecssによる未使用スタイル削除の妨げとなる可能性があります。

purgecssは非常に強力なものの、セレクタを使っているかの判定は静的解析によって為されています。どこまで正確に判定しているのかまでは精査できていませんが、不要なリスクを回避する意味でも、アンパサンドやスクリプトを用いた文字列結合によるセレクタ生成は避けたほうが無難です。

合宿の地、松本にて

grepを阻害する邪悪なアンパサンドによるセレクタ文字列結合は、すでにメドピアのリポジトリ群に多く入り込んでいました。この現状に立ち向かうべく、アンパサンドを使わない素のセレクタ文字列に変換してしまおうと決意を固めます。

そしてメドピアでは年に数回、2泊3日かけて自由なテーマに取り組むことができる開発合宿を開催しています。 ボリューム的にちょうど良さそうだったこともあり、アンパサンド変換用のCLIツール開発をテーマに決めて取り組むことにしました。

採用言語

CLIツールの開発言語にはRustを選びました。フロントエンド関連なのだからJavaScriptやTypeScriptでいいじゃないかという葛藤はもちろんあったものの、舞台はせっかくの開発合宿です。今の最善でないとしても、興味のある技術にフォーカスしてこそ開発合宿です。普段とは違う言語に四苦八苦したくなるときだってあるんです。

成果物

リポジトリはこちら。 github.com

CLI

まずは当初目的通りCLIツールです。clapというCLIツール作成用便利パッケージを使うと引数解析やヘルプ表示などを簡単に作れます。

USAGE:
    sassruist [FLAGS] <path>

FLAGS:
    -f, --fix        fix original file(s)
    -h, --help       Prints help information
    -V, --version    Prints version information

ARGS:
    <path>    target file or directory path

ファイルを書き換えたりといったCLI特有の機能以外はWebAssembly版と同じ動作なので、デモなどはそちらに引き継ぎます。

WebAssembly

フロントエンド、Rustというキーワードが来たら当然続くのはWebAssemblyでしょう。 冒頭で長々と書いた当初の目的を果たすにはCLIツールさえあれば十分ですが、舞台は開発合宿です。隙あらば気になる技術に全力投球していきます。

成果物

netlifyにデモサイトを用意したのでお試しください。 determined-wescoff-282115.netlify.com

妥協点

成果物発表直後ですが早速妥協点紹介です。というのも正直なところ今回の成果物・・・作った本人から見ても実用レベルには至りませんでした、無念。
以下その理由です。

変換結果

「アンパサンドを置換するだけだし実装なんて簡単だろう」と気楽に作り始めてしまったのが運の尽き、Sassアンパサンド(というよりSassそのもの)は想像以上に手強い相手でした。

まずはこの変換例を御覧ください。

f:id:robokomy:20190716135423p:plain
変換例

みにくい!!!

そうなんです。アンパサンドを解決するにはセレクタのネスト構造を解決する必要があり、そのために記述が複雑になることを避けられませんでした。

ちなみに余談ですが、当初は勢いで走りすぎてこういう変換をしていました。間違い探しとしてお収めください。

f:id:robokomy:20190716135917p:plain
間違い探し

制約

100歩譲ってみにくさは我慢するとしても(diffが死ぬので譲りにくいですが)、仕様的に無視しにくい大きな制約もいくつか残ってしまいました。

特に厳しいのは複数行のセレクタに対応できていないことです。

f:id:robokomy:20190716140516p:plain
🤔🤔🤔

見るも無残な姿になってしまいました。「1行ごとに処理すればいいだろう」という謎の自信に満ちた実装方針により、複数行セレクタへの対応が必要と気付いたときには既に軌道修正が間に合いませんでした。

あと地味にSassを諦めてSCSS専用になっています。妥協点満載です。

CLIとWebAssembly両対応ビルド

気を取り直して、今回一番苦戦した点の紹介です。実は実装を差し置いて、CLIとWebAssemblyの両方にRustコードをビルドすることにかなり苦戦しました。
RustやCargo.toml自体への理解度もあまり深くなく、試行錯誤の末になんとか形になった方法をここで紹介したいと思います。

シンプルなWebAssemblyビルド

RustをWebAssembly化し、ウェブページとして公開する1連の流れ自体はこちらの記事を大いに参考とさせていただきました。

webbibouroku.com

bindgen、wasm-pack、create-wasm-appなど、便利なツールの導くままにWebAssemblyなウェブページを作ることができてしまいます。

RustをWebAssemblyにビルドするだけなら解説通りにwasm-packを使うだけです。しかし今回はもう1つのターゲット(こちらが本命のはずなんですが)としてbinaryにもビルドする必要があります。

異なる依存パッケージ

CLIツールとしてはディレクトリ内のファイルを一気に処理させたかったので、dependenciesにwalkdirというファイル探索用パッケージを追加していました。
ウェブページて使うWebAssemblyにファイル操作は不要なのですが、Cargo.tomlのdependenciesに書かれたパッケージがWebAssemblyビルドのときにも認識され、wasm-pack buildコマンドを実行すると以下のようなエラーを吐かれてしまいます。

error[E0433]: failed to resolve: use of undeclared type or module `imp`
   --> /~~~/.cargo/registry/src/github.com-1ecc6299db9ec823/same-file-1.0.4/src/lib.rs:261:9
    |
261 |         imp::Handle::stdout().map(Handle)
    |         ^^^ use of undeclared type or module `imp`

原因は推測ですが、WebAssemblyにビルドできない何か(walkdirなのでおそらくファイル操作系)が混ざっていると判定されているのだと思います。
その証拠に、Cargo.tomlのdependenciesからwalkdirをコメントアウトすると無事にビルドが成功します。

つまり、WebAssemblyにビルドするときだけ手動でその行をコメントアウトすれば解決します!!!

許されません、これは許されません。ビルドコマンドをただ実行するだけなのに、こんなにも露骨な運用でカバープロセスを挟み込むなんて許されるわけがありません。README.mdにこんなビルド方法の解説を書くこともとてもできません。

環境ごとの依存パッケージ変更

本体のコーディング以上に血眼になって探し、ついにここで答えを見つけました。
修正後のCargo.tomlの書き方はこちらです。

gist.github.com

ポイントはclapwalkdirパッケージに付けたoptional = trueというオプションで、featuresとしてbinを指定したとき(cargo build --feautres bin)だけビルド時の依存関係に含めてくれるようになります。

f:id:robokomy:20190705185245p:plain

大まかな流れは上図のようになっています。WebAssemblyとしてビルドするときは不要なパッケージをwasm-pack buildから除外することに成功しました。そしてCLIとしてmain.rscargo buildするときは、--feautres binを引数に加えることで依存パッケージをしっかりと認識してくれます。
引数追加程度ならREADME.mdに書くこともきっと許されることでしょう。

本当はCLIとしてビルドするときにWebAssemblyでしか使わないパッケージも除外したかったのですが、こちらはwasm-packをさらに解析する必要がありそうで今回は泣く泣く断念しました。こちらの問題にも対応した完全版両対応ビルド設定の模索は今後の課題です。

f:id:robokomy:20190705185931p:plain

成果物やデモを公開するには、やはりウェブブラウザで見れるというお手軽さは魅力的です。本命のCLIツールを作り込みつつ、さくっと触れるデモをウェブで公開したいという贅沢な欲求をWebAssemblyは見事に叶えてくれました。

まとめ

Sass/SCSSの邪悪なアンパサンドへのウラミツラミからスタートし、それっぽく動くけど妥協点満載な解決ツールを作り、WebAssemblyの知見を紹介して締めるという全くまとまりのない記事となってしまいましたが、メドピア開発合宿3日間が見事に集約されています。自分で言うのもあれですが見事に集約されています。

実は人生初の開発合宿というイベントでした。好きなテーマに取り組んでいいという我らがCTO福村の言葉をそのまま受け取り、このように本当に好きなことに取り組むことができた濃密な3日間となりました。これで当初目的だったアンパサンド撲滅も達成できていたら最高だったのですが、残念ながら力及ばず。

しかしなんと、同じく合宿に参加していたフロントエンドエンジニア村上(@pipopotamasu)がstylelint-scssのruleでセレクタのアンパサンド結合を禁止するというスマートすぎるPRをさくっと作って本家のルールに加えてくれました。

github.com

邪悪なアンパサンド勢力の拡大はこうして食い止められたのです。松本の地にて立ち上がっていたのは村上です。僕は何もしていません。やはり持つべきものは仲間です。メドピアではフロントエンドエンジニアの仲間を絶賛募集中です。


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


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

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

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

■開発環境はこちら

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