メドピア開発者ブログ

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

Tailwind CSSという風と共に走るフロントエンド開発

10km40分切りが2020年の目標、メドピア長距離部の小宮山です。

みなさんTailwind CSSはご存知でしょうか。tailwindとは「追い風」を意味します。最高に気持ちよく走れるコンディションですね。

目次

サービス概要

まずは今回新たに立ち上げたサービスの紹介です。 「MedPeerスポット×リクルートメディカルキャリア」という医師向けスポット求人マッチングサービス(以下、本サービス)が11月にリリースされました。

medpeer.co.jp

ログインや応募などサービスのコアな部分はMedPeer医師会員限定になってしまいますが、サイトの雰囲気自体は非会員でも十分に味わえますので是非ともサイトを開いてみてください。医師向けスポット求人という普段なかなか見ることができない世界を覗くこともできます。

技術概要

本サービスの技術的な構成をざっと紹介していきたいと思います。

まずはMedPeerといえば(?)なRuby on Rails(以下、Rails)です。最新ほやほやの6.0です。自分はフロントエンド畑な人間でそれほどRailsに精通しているわけでもなくそのすごさをちゃんとは理解していませんが、きっとすごいことなんだと思います。

以前のMedPeerなら、「Railsを使っています、以上です。」で終わってしまっていたところですが、なんと今回はもうひとつ目玉技術があります。Nuxt.js(以下、Nuxt)です。

ja.nuxtjs.org

実はNuxtの利用自体はMedPeerでは初めてではありませんでした。静的サイトのジェネレータとしてシンプルなLPを作成した実績はすでにあります。

かかりつけ薬局化支援サービス「kakari」のLPがまさにそれにあたります。

kakari.medpeer.jp

ではなぜ再度Nuxtを強調し直すのか。なんと今回は、Nuxtを本番運用してSSR(サーバーサイドレンダリング)するというMedPeer初の挑戦だったのです。さらにバックエンド(Rails)とフロントエンド(Nuxt)をリポジトリもAWSリソースも分離してしまうというMedPeer初だらけの野心的な技術構成となっていました。

そして非常に申し訳ないのですが、今回伝えたいのはNuxtのことでもフロントエンド分離のことでもありません。開発プロセスやVue.jsの話題ですらありません。それ以上にTailwind CSSのことを伝えたいモチベーションが高すぎました。新大陸の大地そのものよりも、そこに吹いていた追い風にこの心を掴まれてしまったのです。

Tailwind CSSとは何か

tailwindとはずばり「追い風」を意味します。今日の走りは絶好調だと思ったら折返しで現実に引き戻されるやつですね。

公式サイトの文言をここに引用します。

A utility-first CSS framework for rapidly building custom designs.

Tailwind CSS is a highly customizable, low-level CSS framework that gives you all of the building blocks you need to build bespoke designs without any annoying opinionated styles you have to fight to override.

一応CSSフレームワークという分類になりそうですが、BootstrapVuetifyといった所謂一般的なCSSフレームワークとはやや毛色が異なります。

一般的なCSSフレームワークの多くが「コンポーネント」を提供してくれるのに対して、Tailwind CSSが提供してくれるのはスタイルを便利に指定するためのツール類だけです。

例えばダイアログを作りたいと思ったとき、Vuetifyならコンポーネントとして既に用意されていますが、Tailwind CSSでは用意されたツールを組み合わせてダイアログというコンポーネントを自力で作らなければいけません。Tailwind CSSが提供してくれるのは自動車本体ではなく、その名前が意味する通り、自力で走る者たちへの追い風です。

HTMLとCSS

Webページを構成する3要素といえば、HTML・CSS・JavaScriptです。HTMLはWebページの構造を、CSSはレイアウトや装飾を、JavaScriptは何かをしてくれます。
(今回はJavaScriptについてはあまり掘り下げません。)

ja.wikipedia.org

文書の構造と体裁を分離させるという理念を実現する為に提唱されたスタイルシートの、具体的な仕様の一つ。

令和を生きる私はCSSが登場したころの状況を実際には知らないのですが、どうやらCSSはHTMLからスタイルを分離させることを目的に生み出されたもののようです。そして実際に、文章構造とスタイルやHTMLとCSSそれぞれに切り出して役割分担させるのがWebの作法だという認識が広く広まっていたと思います。

HTML

<ul class="content-list" >
  <li class="content-item">item 1</li>
  <li class="content-item">item 2</li>
</ul>

CSS

.content-list { display: flex; }
.content-item { padding: 1rem; }

なんの変哲もない例を出すとこんな感じでしょうか。HTML側ではタグにclassを付け、CSS側ではそのclassをセレクタとしてスタイルを付与しています。

一方でこんなHTMLはどうでしょうか。

HTML

<ul style="display: flex;" >
  <li style="padding: 1rem;">item 1</li>
  <li style="padding: 1rem;">item 2</li>
</ul>

スタイルをCSSに分離することなく、タグにそのまま書き込んでいます。所謂インラインスタイルです。基本的に、インラインスタイルが歓迎されることはほとんどないと思われます。実際に私もこのコードだけを見たらレビューコメントを入れる手を止めることはできないでしょう。

しかしなぜ歓迎されないのでしょうか?実のところ「そういうもの」で終わらせてしまうことが多く、あまり深く考えたことはありませんでした。実際にCSSは誕生目的からして「そういうもの」なのですが、改めてその問題点をざっと考えてみました。

  • HTMLが冗長になる
    class指定が不要とはいえ、styleで書き込むので文字数の絶対量は増えてしまいます。
  • スタイルの共通化ができない
    タグ毎にstyleを書き込むしか無いので、リストアイテムなどには大量に同じstyle属性を書き込まないといけません。HTML冗長化をさらに後押しします。
  • CSS関連のエコシステム(Sass、PostCSS、Stylelintなど)が使えない
    最近のフロントエンド開発ではこれが相当のデメリットではないでしょうか。
    (もしかしたらインラインスタイルにもいい感じに変換やlintを当ててくれる仕組みがあるかもしれません。)
  • レスポンシブ対応が難しい
    若干思いつきベースですが、インラインスタイルでレスポンシブなスタイルって作れるんでしょうか?やるなら下記のようになりそうですが、試そうと思ったこともなさすぎて効くのかどうかは未検証です。
<ul style="@media (min-width:480px) { display: flex; }"> ... </ul>

HTMLとCSSとコンポーネント

時代は変わりつつあります。既に変わってしまっているのかもしれません。その変化の主役となる存在がコンポーネントです。HTMLとCSSは文章構造とスタイルという役割で分離するのが当たり前だったのに、コンポーネントという新たな区切がより重視されるようになってきています。

従来のWebページ構成イメージ
従来のWebページ構成

コンポーネント指向なWebページ構成その1のイメージ
コンポーネント指向なWebページ構成その1

コンポーネント指向がもたらす大きなメリットは、コンポーネントという明確な区切りが生まれることです。今までのHTML・CSS・JavaScriptという3層では、互いに分離されてはいるものの、その層内はほぼグローバル空間と化していました。良く言えば自由で手軽に作れてすぐに変更可能、悪く言えば分かりにくく壊れやすくて変化に弱い。

コンポーネントという区切りは、この3層のより上位の区切りとしてWebページのグローバル空間を分割します。そしてさらにコンポーネント内でHTML・CSS・JavaScriptの3層が存在するようなイメージです。

双方ともにメリットとデメリットは様々にありますが、今のフロントエンド界隈はコンポーネント指向が勢い付いていると見て間違いないでしょう。

シンプルに考えると、Webページがコンポーネントによってまず区切られ、コンポーネント内部では従来の3層区切りがそのまま再現されているだけです。確かにコンポーネントが注目されだした当初はそうだったかもしれません。

しかし変化はそこで留まってはくれませんでした。コンポーネントという閉じた安全な世界が作られたことで、その内部ではかつて常識と考えられていた壁の存在感が薄れだしました。

コンポーネント指向なWebページ構成その2のイメージ
コンポーネント指向なWebページ構成その2

コンポーネントとして完結していて、外部との連携で求められるインタフェースが満たされてさえいれば、コンポーネント内部の実装が多少暴れん坊になっていても大した問題にはなりません。いや問題ではあるんですが、コンポーネント内部はいわばprivateな部分なので大した問題ではないことがほとんどです。コンポーネントという区切りが明確に存在しているので、どうしようもなくなったら丸ごと取り替えてしまうことだって可能です。

かくしてコンポーネントの躍進により、HTMLとCSSの境界は薄れだしました。もちろん完全になくなったわけではありませんが、CSSが生まれた目的そのものであったHTMLとスタイルの分離は、コンポーネントというより大きな関心事にかき消されつつあります。

コンポーネントと雑なセレクタ

コンポーネントという概念によってHTMLとCSSの間にあった従来の区切りは曖昧になり、両者の距離はぐっと近づいています。区分けされた両者を繋ぐために必須だったclassを軽視する、こんな手抜きスタイル指定も横行しているのではないでしょうか。

HTML

<ul class="content-list" >
  <li>item 1</li>
  <li>item 2</li>
</ul>

CSS

.content-list { display: flex; }
.content-list > li { padding: 1rem; }

階層を指定しているとはいえ、セレクタとしてliを直で指定しています。従来のHTML・CSS分離の方針に従うなら真っ先にレビュー指摘が入りそうなコードです。実際に私もこんなコードを素の状況で見つけたらおそらく指摘を入れます。ただしこのコードがコンポーネントに関するものであれば話は別で、CSSにHTMLの構造が漏れ出していてもそれほど気にしません。

理由は2つあります。1つはスタイルがコンポーネント内に閉じているからです。liという豪快なセレクタ指定がされていようと、そのセレクタがコンポーネント内のHTMLに限定されているのなら、コンポーネント外の予期せぬ箇所でスタイルが崩れることはありません。
(セレクタのパフォーマンス観点については今回は立ち入りません。)

もう1つは、classを丁寧に付けるほうがむしろコストが高くなるかもしれないからです。classを増やせば当然HTMLの文字数は増えて汚れます。適切なclass名を文書構造と翻訳サイトを往復しながら考えなくてはなりません。HTMLとCSS間の視点移動だって必要です。つまり面倒くさいのです。単発では小さな手間かもしれませんが、それを数百数千回と繰り返すのはとても面倒くさいのです。人生は短いんです。早く家に帰って走りたいんです。

コンポーネントとインラインスタイル

コンポーネントという箱庭の存在によって、雑なセレクタが許されるようになってきたとしましょう。エンジニアというのはどこまでも怠惰を求める生き物らしいです。当然、一度雑になったセレクタ指定はどこまでも雑になっていきます。雑になればなるほどHTMLとCSSは近づいていきます。そして行き着く先に何があるかというと、インラインスタイルの再登場です。

HTML

<ul style="display: flex;" >
  <li style="padding: 1rem;">item 1</li>
  <li style="padding: 1rem;">item 2</li>
</ul>

コンポーネント指向という前提に立ち、改めて先に挙げたインラインスタイルのデメリットを再評価してみます。

  • HTMLが冗長になる
    基本的には従来と同様だと思います。
  • スタイルの共通化ができない
    コンポーネント単位での共通化という大きな道が開けます。利用するフレームワーク依存となりますが、v-forjsxなどで効率的に記述する手段も多くあります。
  • CSS関連のエコシステム(Sass、PostCSS、Stylelintなど)が使えない
    基本的には従来と同様だと思います。
  • レスポンシブ対応が難しい
    コンポーネントごと表示を切り替えるなどの手段が生まれてきそうですが、ほぼ従来と同様と思われます。

ただしHTMLの冗長さやCSSエコシステムについては、コンポーネント指向な開発の現場でよく使われるstyled-componentsやCSS in JSなどのJavaScript側からのアプローチによって解決は可能かもしれません。
(普段Vue.jsのscoped CSSばかり使っているのでこのあたりの動向には疎いです。)

場面によってはインラインスタイルで済ませてしまうことも十分に可能ですが、大勢をそれだけでなんとかするのはやはり厳しい印象です。特にCSS系のエコシステムとの相性と、レスポンシブ対応のやりにくさは致命的です。インラインでベンダープレフィックスを付けていく作業以上の苦痛はなかなか見つからないと思います。人生は短いし早く帰って走りたいんです。

Tailwind CSSがもたらしたもの

やっとTailwind CSSの話題にたどり着きました。先に挙げたインラインスタイルの例をTailwind CSSで書くとこうなります。

HTML

<ul class="flex" >
  <li class="p-4">item 1</li>
  <li class="p-4">item 2</li>
</ul>

ご理解いただけたでしょうか。ここまで長々と紹介してきたコンポーネントとスタイル指定に関する内容そのものが、Tailwind CSSの良さへと至る道筋です。コンポーネント化の波がもたらしたスタイル指定の怠惰の極みにあるインラインスタイル、良さもあるが辛さも多量に含んだその地点に到達する一歩手前に我々を留めてくれる存在、それこそがTailwind CSSです。

見ての通り、Tailwind CSSはスタイルとほぼ1対1に対応したclassを提供してくれます(一部複合的なスタイル付きclassがあったり、独自で拡張することも可能です)。つまり実質class方式でスタイルを指定しているだけなので、autoprefixerpurgecssなどとの連携が容易です。

ブレークポイント用のプレフィックス(sm:, md:, lg:)が用意されているので、レスポンシブ対応に苦慮する必要もありません。

例えばPCサイズ以上でだけ横並びにしたい場合はこう書けます。インラインスタイルの良さを残しつつ悪さを緩和する、絶妙な具合ではないでしょうか。

HTML

<ul class="flex flex-col md:flex-row" >
  <li class="p-4">item 1</li>
  <li class="p-4">item 2</li>
</ul>

HTMLが冗長になるという点に関しては、インラインスタイルよりは改善されているものの、class形式でスタイルを当てていくときほどには軽量にならないです。スタイルとclassがほぼ1対1になっているので、この点は受け入れるしかないというのが現時点の評価です。

まとめ

Tailwind CSSは決して強力なCSSフレームワークではありません。フレームワークと呼んで良いのか怪しいくらいに具体的なものは何も提供してくれません。公式サイトに掲げられている通り、utility-firstなツールを提供してくれるだけです。ツールをどう使うか、何を作るかは完全に使い手に委ねられています。

そこに心地よさを感じかどうか個々人で評価が分かれるかもしれません。しかしながら本サービスの立ち上げからリリースまでTailwind CSSを使い続けた私の感想としては、「次もまた使いたい」し「発展に貢献したい」です。

その理由を端的に表現するのはとても難しいです。なぜなら具体的にこれが良いと言い切れるようなものではなく、この記事で述べてきたように、Webフロントエンドの潮流の中で味わう様々な要因が組み合わさって到達した良さだからです。しかしその良さを実感しているのは事実です。

今回はTailwind CSSの使い方自体はほとんど紹介できませんでしたが、最近は利用事例も増えて情報も多く出ているので探すのに困ることはないと思います。需要があればMedPeerでのTailwind CSS利用事例として改めて記事化するかもしれません。

Tailwind CSSの巻き起こす追い風、みなさんも是非味わってみてください。


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


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

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

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

■開発環境はこちら

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

メドピア16期目初の開発合宿@熱海を開催しました!

みなさんこんにちは!

10月からメドピアにジョインしました、サーバサイドエンジニアの福本です。

メドピアでは日常業務から離れ、業務改善や技術研鑽のための開発合宿を定期的に開催しております(ちなみに、前回の開発合宿の様子はこちら↓)。

tech.medpeer.co.jp

私は今回が初めてのメドピア開発合宿だったのですが、すごく楽しくて有意義な時間だったので、合宿の様子をみなさんにもお届けできればと思います。

開発合宿のプログラム

今回の開発合宿は、11月20日(水)~22日(金)の2泊3日で行われました!

開発するテーマは自由なのですが、当日に内容に悩んで時間を無駄にしないように、事前に内容をエンジニアのメンバーに事前に決めてきてもらいます(前もって準備をしてきてもOK)。

また、せっかく時間を掛けて取り組んだ内容なので、開発合宿でのアウトプットを各自が発表する場を設けているのですが、合宿中に発表まで行ってしまうと開発に使う時間が短くなったり、資料に掛けられる時間が少なくなってしまいます。

そのため、成果発表は合宿が終わった次の週にオフィスで開催することにし、合宿中は思う存分開発に集中できるようにしました。

発表の様子もこちらの記事でお伝えしますので、ぜひ最後までお付き合い頂けると幸いです!

1日目

f:id:ec0156hx39:20191121182751j:plain

待ちに待った開発合宿は、お昼過ぎからの開始!熱海駅に1名を除きエンジニアメンバー全員で集合し、会場まで向かいました。

ちなみに、今回予約した宿泊所はこちらです↓

www.airbnb.jp

Airbnbで予約したのですが、地下1階から5階まで広く自由に使えますし、屋上で足湯に入りながら開発できたり、地下にはゲームやカラオケがあるなど、みんなで楽しみながら(後述)開発できるような環境になっています。館内のWi-Fiも速く、チーム開発も問題なく進められるのでオススメです。

f:id:terryyy:20191124180829p:plain

まずは会場で全員集合して、各メンバーが取り組む開発テーマを改めて発表し、その後は各自で自由に開発を行います。足湯に入ってリラックスながら開発をするメンバー、机と椅子でバッチリ集中して開発をするメンバーと、色々な開発スタイルが見られて面白かったですね。

f:id:terryyy:20191124182909j:plain f:id:terryyy:20191124183007j:plain

夜は熱海...ということもあり、休憩がてら近くのスパや温泉を楽しみに行くメンバーも。ちなみに、夜ご飯は参加メンバー全員で近くのお店に食べに行きました。周辺に飲食店が充実しているというのも、熱海のいいところ。

f:id:terryyy:20191124183848j:plain f:id:terryyy:20191124183936j:plain

部屋に戻ってからは、各自で開発に戻りました。

2日目

2日目は移動がないので、朝起きてから夜遅くまで、ひたすら開発に集中できる貴重な一日でした(そのためブログに書くことも少ない....)!

とはいえ気分転換も大事。朝食がてら朝日を拝みに行ったり、

f:id:terryyy:20191124184939j:plain

お昼ごはんは、みんなで海鮮を食べに行ったりしました。

f:id:terryyy:20191124185530j:plain

そんな2日目ですが、なんとRailsに送ったPRをマージされたメンバーがおりました...(すごい)。合宿で取り組んでいるメインのテーマではなかったようですが、目に見える形でわかりやすく成果が出るのは非常に嬉しいですね!

f:id:terryyy:20191124185901p:plain

画像にもURLありますが、マージされたPRは以下。よろしければご覧ください。

github.com

3日目

楽しい開発合宿も最後ですが、合宿所の予約日時の都合上、3日目は場所を近くのコワーキングスペースに移して開発を進めました。場所は同じく熱海にありますnaedocoさん。

naedoco.jp

naedocoさんは2016年にできたばかりのコワーキングスペースで、今までの開発場所とはまた違った雰囲気の中、リフレッシュして開発をすすめることができました。

f:id:terryyy:20191124190852j:plain

プログラム上は15時に解散、その後は各自自由に帰宅・開発という流れだったのですが、集中するあまりコワーキングスペースの開場時間ギリギリまで開発をするメンバーも居ました。

そんなこんなで、3日間の開発合宿は終了。各メンバーがどこまで進捗したのかはお互いあまり知らない状態だったので、成果発表がすごく楽しみです。

成果発表LT

厳密には合宿...ではないのですが、土日のお休みを挟み、開発合宿の成果発表会を社内LTのような形で実施いたしました!

f:id:terryyy:20191125235251j:plain

合宿に参加できなかったエンジニアはもちろんですが、参加したエンジニアも合宿中には自分たちの開発内容に没頭していたこともあり、開発合宿の進捗や得た学びを改めて確認する場になりました。

開発テーマの内容は本当に様々で、例えば人工知能系のチャットボットについてがっつり調査を進めてくれたメンバーも居れば...

f:id:terryyy:20191125235715j:plain

心拍数を測り、PCのカメラから撮影した動画の顔に計測結果を写す」という、日常のWeb開発の業務ではなかなか触れることのない技術を使いこなしてアウトプットをしたメンバーも。

f:id:terryyy:20191126000114j:plain

参加したエンジニア全員が発表したところで、開発合宿は完全に終了!

改めて振り返ってみると、単純に楽しかったというのはもちろんですが、参加したエンジニア全員がしっかりと開発テーマを進捗させて、アウトプットできる形まで持っていっている、という部分が印象に残る合宿でした。

初めて触れる技術をテーマにしていたエンジニアも多い中で、適度に楽しみながらも、集中すべきところはしっかりと集中してやり切る姿に、メドピアのエンジニアの底力を見たように思います。

さいごに

f:id:terryyy:20191124193411j:plain

最後までお付き合い頂き、ありがとうございました!

メドピアという会社が、事業も開発チームも大きくなっていく中で、多くのエンジニアメンバーが3日間も集中できる時間を頂けるということは決して当たり前のことではなく、会社全体の理解と協力があってのことで、非常にありがたいことだと改めて感じました。

今後も開発合宿という文化を継続して開催していけるよう、事業に技術でしっかり貢献していきます。

また、今回の合宿で培われた技術によってメドピアのサービスが改善されたり、あるいは新しい事業やサービスを産みだされることで、より多くの医師を支援し患者を救うことに繋がればと考えております。10月から16期目を迎えたメドピアを、みなさまどうぞ応援よろしくお願いいたします!

 


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


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

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

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

 

■開発環境はこちら

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

メドピア開発合宿でVue.jsテストライブラリ「vue-function-tester」を作った話

涙の数だけ強くなるフロントエンドエンジニア村上(@pipopotamasu)です。

先週の水木金とメドピア恒例の開発合宿 in 熱海に行ってきたので、そこで作ったVue.jsのテストライブラリ「vue-function-tester」を紹介したいと思います。

github.com

f:id:ec0156hx39:20191121182751j:plain
atami

vue-function-testerとは

vue-function-testerは、Vue.jsの「メソッドの単体テストライブラリ」として作りました。
もっと具体的に言うと、Vue.jsのmethods, lifecycle hooks, computed, watchをテストするためのライブラリです。
※ちなみに「vue-method-tester」にしなかったのはmethodsプロパティと被ってややこしくなるのでやめました

Get started

長々と解説するよりは実際のコードをみて行きましょう。
テスト対象のコンポーネントとして、簡単な検索ボックスのサンプルコードを用意しました。

// SearchBox.vue
export default Vue.extend({
  data () {
    return {
      query: '',
      error: ''
    }
  },
  methods: {
    search () {
      this.error = ''
      if (this.query === '') {
        this.error = "Please input."
      } else {
        this.$emit('search', this.query)
        this.query = ''
      }
    }
  }
})

上記のsearchメソッドに対して、vue-function-testerでテストを書くと以下のようになります
(※ jest依存ライブラリなのでjestのテストコードになります。余談ですがメドピアのフロントエンドはjestでバシバシテストコードが書かれています。)

# SearchBox.spec.js
import SearchBox from "@/components/SearchBox.vue";
import { methods } from "vue-function-tester";

describe("search()", () => {
  const { search } = methods(SearchBox);

  describe("no query", () => {
    it("show error", () => {
      expect(search().run({ query: "", error: "" }).error).toBe("Please input.");
    });
  });

  describe("with query", () => {
    it("emits query", () => {
      const result = search().run({ query: "test", error: "" });
      expect(result.$emit).toBeCalledWith("search","test");
      expect(result.query).toBe("");
    });
  });
});

上記のようにsearchメソッドに対するテストを書くことができます。 lifecycle hooksやcomputedのテストに関しては以下をご覧ください。

https://github.com/pipopotamasu/vue-function-tester#lifecycle-hooks https://github.com/pipopotamasu/vue-function-tester#computed

vue-function-testerを作った背景

ただなんとなく作りたかっただけです。深い理由はありません。

(上記だとブログの長さ的に寂しくなりすぎるので、理由を無理やり捻り出しました。)

vue-function-testerを使わなくてもVueコンポーネントのメソッドのテストを書くことは可能です。 例えば...

  • vue-test-utilsを使用する
  • メソッド内の処理を別の関数として切り出してそちらをテストする
  • VueオブジェクトもしくはVueコンストラクタからメソッドの参照を取り出しテストする

ざっと考えついただけ3つの方法がありますが、どれも良い面もあればそうでない面もあり、個人的にときめくものではありませんでした。

vue-test-utils

皆さんご存知の通り、既存のVue.jsのテストライブラリとして「vue-test-utils」という有名なライブラリがあります。
とても便利なライブラリで私ももちろん愛用させてもらっています。

しかしこれは「Vueコンポーネント」のテストにフォーカスしたライブラリです。 常日頃開発している身としては、「Vueコンポーネントのメソッド」にフォーカスしてテストを書きたい時にちょくちょく出くわします。
その場合、vue-test-utilsだとテストコードが冗長になってしまうことがあります。

  • テストの度に毎回mountが必要
  • mount時に初期設定が必要な場合がある
    • テストしたいメソッドとは関係のないpropsを用意する
    • created hooks用の設定
    • etc...

等々、Vueコンポーネントのメソッドテストをときめく感じに書きたいという欲を満たすことができませんでした。

VueオブジェクトもしくはVueコンストラクタからメソッドの参照を取り出しテストする

上記のサンプルコードを例にすると以下のような感じになります。

# SearchBox.spec.js
import SearchBox from "@/components/SearchBox.vue";

describe("search()", () => {
  const { search } = (SearchBox as any).options.methods;

  describe("no query", () => {
    it("show error", () => {
      const context = {
        query: "",
        error: ""
      }
      search.call(context);
      expect(context.error).toBe("Please input.");
    });
  });

  describe("with query", () => {
    it("emits query", () => {
      const context = {
        query: "test",
        error: "",
        $emit: jest.fn()
      }
      search.call(context);
      expect(context.$emit).toBeCalledWith("search", "test");
      expect(context.query).toBe("");
    });
  });
});

テスト自体は普通に書けるのですが、若干冗長さを感じてしまいます。え、私だけ...? 特に上記のコードで冗長と感じるのは以下の2点です。

  • thisのcontextを別変数で宣言->メソッド実行->context変数の検証と、3ステップ踏まないといけない。
  • $emitのモック作成。methodsやcomputedのsetterのテストを書いているとそこそこの頻度で$emitが出てくるのに毎回モックするのが面倒。

Vueコンポーネントのメソッドにフォーカスするという部分では問題ないのですが、上記の点でときめかない点が残りました。

メソッド内の処理を別の関数として切り出してそちらをテストする

そもそも別関数として切り出すというやり方です。
関数のインターフェースをcontextベース(this)ではなく、引数・返り値ベースでinput/outputを実装すればとてもテストしやすい関数になります。
(※ contextベースだと結局、contextを別変数で宣言->メソッド実行->context変数の検証と、3ステップ踏まないといけない)

export function sum(lfs, rhs) {
  return lfs + rhs;
}

export default Vue.extend({
  data() {
    return {
      lfs: 1,
      rhs: 2,
      result: 0
    };
  },
  methods: {
    sum() {
      this.result = sum(this.lfs, this.rhs);
    }
  }
});

簡単すぎる例ですが、上記のsum関数なら容易にテストできます。
特にロジックが長くなってくると、Vueコンポーネントのメソッドとして定義しておくとコンポーネント全体の視認性が悪化するので上記のような分割はとても良い手段です。

ただテストのために全てのロジックを外部の関数に切り出すのはどうでしょうか? そもそも切り出すのがめんどくさいですし、切り出した関数のテストが正でも対象のVueコンポーネントのメソッドが正常に動くとは限りません。

こちらの手段も若干ときめかない部分が残りました。

vue-function-testerのときめくポイント

ではどうすればときめくのか、それはコードを短く書くことではないかと自問自答し、以下のコードを短くするときめきポイントを実装しました。

ときめきポイントその1: メソッドチェーン

以下のようにメソッドチェーンで繋げることで、ワンライナーで検証できるようにしました。

import { methods } from "vue-function-tester";
const { search } = methods(SearchBox);

expect(search().run({ query: "", error: "" }).error).toBe("Please input.");

ときめきポイントその2: alias

さらにaliasを貼り文字数を削減。

import { methods } from "vue-function-tester";
const { search } = methods(SearchBox);

// 通常
expect(search().run({ query: "", error: "" }).error).toBe("Please input.");
// aliasその1
expect(search().r({ query: "", error: "" }).error).toBe("Please input.");
// aliasその2
expect(search.run({ query: "", error: "" }).error).toBe("Please input.");
// aliasその3
expect(search.r({ query: "", error: "" }).error).toBe("Please input.");

※メソッドに引数を与えなければならない時はaliasその2とその3は使えません

ときめきポイントその3: $emit

使用頻度の高い$emitを事前にモック化

import { methods } from "vue-function-tester";
const { search } = methods(SearchBox);

expect(search.r({ query: "test", error: "" }).$emit).toBeCalledWith("search","test");

終わりに

このようにメドピアの開発合宿では自作のライブラリを作ったり、他のOSSにPRを送ったり、使ったことのない技術を使って何かしらのサービスを作ってみたり等々、参加者それぞれが自由に課題を設定しコードを書きます(年2回、水木金の2泊3日)。

f:id:ec0156hx39:20191122141942j:plain

今回で14回目の開発合宿となりましたが、まだまだ今後も続けていくので開発合宿をしたくなったら是非メドピアへ!


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


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

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

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

■開発環境はこちら

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

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