メドピア開発者ブログ

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

最小手数で始めるTailwind CSS

パクチーパクパク小宮山です。
掲題通りTailwind CSSの始め方を最小手数で書いていきます。余談は一切ありません。

tl;dr

CSS管理は諦めてTailwind CSSを使おう。

Get Started

tailwindcss.com

ひたすら公式通りに進めます。例によってフロントエンドプロジェクトの環境構築はひたすら面倒なので、Tailwind CSS以外のツールチェインはなるべく使わない構成を目指します。

installします。

$ yarn init
$ yarn add tailwindcss

セットアップします。

$ yarn tailwindcss init

こういうファイルが作られました。

tailwind.config.js

module.exports = {
  purge: [],
  theme: {
    extend: {},
  },
  variants: {},
  plugins: [],
}

スタイルのエントリーポイントなるCSSファイルを作成します。ファイル名は任意です。このファイルをTailwind CSSが用意しているCLIでビルドすることで、実際にhtmlファイルで読み込むCSSファイルが出力されます。

tailwind.css

@tailwind base;

@tailwind components;

@tailwind utilities;

TODOリスト感のある素朴なHTMLファイルを用意します。この時点ではpublic/style.cssはまだ生成されていません。

見た目だけの実装なのでフォームも飾りです。

public/index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="style.css">
    <title></title>
  </head>
  <body>
    <h2>New Todo</h2>
    <form>
      <input type="text" />
      <button>submit</button>
    </form>
    <h2>List Todo</h2>
    <ul>
      <li>
        <p>todo 1</p>
        <p>2020 05/12</p>
      </li>
      <li>
        <p>todo 2</p>
        <p>2020 05/12</p>
      </li>
      <li>
        <p>todo 3</p>
        <p>2020 05/12</p>
      </li>
    </ul>
  </body>
</html>

public/style.cssを生成するためのscriptを用意しておきます。

package.json

{
  "name": "minimum-tailwindcss",
  "version": "1.0.0",
  "scripts": {
    "build:css": "tailwindcss build tailwind.css -o public/style.css"
  },
  "dependencies": {
    "tailwindcss": "^1.4.6"
  }
}

実行します。

$ yarn build:css

お好きなwebサーバーを起動してpublic/index.htmlを開きます。package.jsonを汚したくなかったのでnpxでずるして最小手数の体裁を保ちます。

$ npx http-server ./public

スタイルが何もあたっていない状態のwebサイトが完成しました。Tailwind CSSにはnormalize.cssが含まれている(v1.4.6時点)ので、リセット系CSSを別で用意する必要はありません。

f:id:robokomy:20200518174836p:plain
ネイキッドウェブサイト

準備が整ったので早速Tailwind CSS流にスタイルを当てていきます。

  <body>
    <h2 class="mb-2 px-2 text-xl">New Todo</h2>
    <form class="mb-4 px-4">
      <input type="text" class="p-2 border" />
      <button class="ml-2 p-2 rounded text-white bg-blue-500">submit</button>
    </form>
    <h2 class="mb-2 px-2 text-xl">List Todo</h2>
    <ul class="py-2 px-4">
      <li class="p-2 border">
        <p class="border-b">todo 1</p>
        <p class="text-sm">2020 05/12</p>
      </li>
      <li class="mt-2 p-2 border">
        <p class="border-b">todo 2</p>
        <p class="text-sm">2020 05/12</p>
      </li>
      <li class="mt-2 p-2 border">
        <p class="border-b">todo 3</p>
        <p class="text-sm">2020 05/12</p>
      </li>
    </ul>
  </body>

大分それっぽくなりました。

f:id:robokomy:20200511191309p:plain
それっぽい見た目

使い方は見た目通りで、classがそれぞれ特定のCSS定義として用意されています。

例えばp-4ならpadding: 1rem;mt-2ならmargin-top: 0.5rem;といった感じです。インラインスタイルを簡略化したような使用感です。

デフォルトスタイル余談

tailwindcss.com

Tailwind CSSを使う上でまず最初に注意したほうがよいことは、line-heightのデフォルト値です。デフォルトでline-height: 1.5;htmlにあたっているので、全ての余白を自力で指定してピクセルパーフェクトを目指す場合は少し厄介です。

htmlに反映されていることもあり、気にせずスタイルを当てていって途中で変更したくなってしまうと相当な被害になることが予想されます(私です)。それっぽいline-heightを全体に当てておくか、パーフェクトを目指して全てを自力で当てるかの方針はなるべく早期フェーズでの選択がおすすめです。

Vendor Prefixes余談

ターゲットとするブラウザ次第では必要になるであろう、みんな大好きVendor Prefixesです。結論を言ってしまうとTailwind CSS自体にはVendor Prefixes的な対応は入っていません。

それを解決するのはもっとうまくやれる他のツールに任せているというのが公式スタンスです。ドキュメントでもAutoprefixerとの併用が紹介されています。

tailwindcss.com

この先はもうPostCSSの話題になってしまうので深入りはしませんが、Tailwind CSSをPostCSSのプラグインとして利用することも可能なので導入もそんなに手間ではありません。

ファイルサイズ問題

Tailwind CSSを活用する上で無視することのできない非常に重要な問題がファイルサイズです。

実際にtailwindcss buildを叩いてみた方なら、このような表示がされて既に嫌な予感を持っていたかもしれません。

   🚀 Building... tailwind.css

   ✅ Finished in 1.56 s
   📦 Size: 1.95MB
   💾 Saved to public/style.css

✨  Done in 4.11s.

「📦 Size: 1.95MB」です。これは相当に大容量です。normalize.cssが含まれているとはいえ、scriptもfontも含まれていないただのCSSファイルでこれは流石に無視できるサイズではありません。

ファイルサイズが肥大化する理由は明白で、p-4mt-2といったCSSのプロパティと数値の組み合わせが無数に存在するからです。さらにレスポンシブ対応でsm:p-4なんて指定も用意されているので、それら全てが含まれていると考えれば膨れるのも当然なわけです。

実はあのBootstrapにもこのようなutility的なclass群は存在しています。しかし用意されているものは必要最低限で、Tailwind CSSほどの汎用性も拡張性もありません。

内部事情までは知りませんが、おそらくファイルサイズの肥大化という問題は少なからず意識して絞っているのではないでしょうか。

getbootstrap.com

Bootstrapがおそらく敢えて避けているであろう、ファイルサイズがひたすら肥大化していくutility的なclass群という方向にTailwind CSSは振り切っているわけです。その方向に振り切る以上、便利さと引き換えにファイルサイズは諦めなければならない・・という時代もかつてはあったのかもしれません。しかし今は令和です。あれも欲しい、これも欲しいもっともっと欲しいを実現してくれる強力なツールが存在します。

PurgeCSSです。

PurgeCSS

purgecss.com

まただよ、またフロントエンド開発環境に登場人物が増えたよ即ブラウザバックしかけた方はちょっとだけ待ってください。なんとTailwind CSSは最近のリリースでPurgeCSSも内包するようになったので、設定ファイルを微修正するだけです。Tailwind CSS陣営としても、ファイルサイズ肥大化は重要な問題で、その解決法を明示する必要があると判断したのでしょう。

github.com

デフォルトの設定ファイルから、purge部分を少しだけ変更します。Tailwind CSSのclass表現を使っているファイルが全て含まれるようにパスを指定します。そうすることで、そのファイル内で現れていない不要なclassが全て削除されます。

tailwind.config.js

module.exports = {
  purge: ['./public/**/*.html'],
  theme: {
    extend: {},
  },
  variants: {},
  plugins: [],
}

パージ機能を有効にするにはNODE_ENV=productionの指定が必要です。早速実行してみます。

NODE_ENV=production yarn build:css
   🚀 Building... tailwind.css

   ✅ Finished in 1.29 s
   📦 Size: 12.08KB
   💾 Saved to public/style.css

✨  Done in 1.87s.

CSSのファイルサイズ肥大化は、人類にとって最早克服された問題だったのです。PurgeCSSが存在するからこそTailwind CSSのutility-firstという方針が成立すると言っても過言ではないかもしれません。これぞシナジーです。Cookie Clickerをやり込んだ皆さんなら、シナジーが如何に強力かつ重要なのかは身をもって体験しているはずです。

続けて拡張の話題に移りますが、そこでもPurgeCSSという存在が控えていることが非常に重要となります。

PurgeCSS余談: 禁忌事項

PurgeCSSを使う上で、いつか足元を撃ち抜くかもしれない禁忌事項が1つあります。それは、classを必ず完全な形で記述する」ことです。

例えばfont-sizeを動的に指定しようとして、こんな記述をしてしまうかもしれません。

fontSize = 'text-' + size; // size: 4 | 6 | 8

撃ち抜きました。完全に撃ち抜いて水中から氷の天井を見上げています。理由は単純で、PurgeCSSは正規表現によって、使われているclassを探します。つまり動的に生成されたclassは発見のしようがなく、無慈悲にproductionビルド時に削除されます。

多少遠回りになっても、classを完全一致な文字列としてファイル内に記述しなければいけません。例えばこのように。

fontSize = { 4: 'text-4', 6: 'text-6', 8: 'text-8' };

PurgeCSSの要請からこのような完全一致で書く必要があるわけですが、CSSセレクタをこのように完全一致で書くことを習慣つけるのはものすごくおすすめです。以前似たような話題で開発ブログも書きました。

tech.medpeer.co.jp

以前まではgrepがしにくいというやや個人的かもしれない理由だったんですが、今ではPurgeCSSの要請という強力な後ろ盾を得たのでバンバン推していきます。

拡張

デフォルトで用意されているclassでも不便はあまりないんですが、どうしてもそれだけでは足りないシーンというのもあります。

例えばTailwind CSSはremベースの指定が基本となっています。p-4ならpadding: 1rem;'、p-6なら1.5rem`といった具合です。

なんかそれっぽい感あってrem指定いいですよね。しかし残念ながら世の中そんな甘くなく、往々にしてpx単位ベタ打ちのピクセルパーフェクトを求められてしまうことだってあります。pxremに変換してなんとか表現するという努力も悪くないですが、なかなかに不毛な作業です。

そんなときはさくっとTailwind CSSを拡張してしまいましょう。

tailwind.config.js

module.exports = {
  purge: ['./public/**/*.html'],
  theme: {
    extend: {
      spacing: {
        // px単位
        ...[...Array(120)].reduce((m, _, i) => {
          m[`${i}px`] = `${i}px`
          return m
        }, {}),
      },
    },
  },
  variants: {},
  plugins: [],
}

豪快にp-1pxからp-120pxまで用意してみました。ピクセルパーフェクトし放題です。僅かばかりの良心で120pxにしましたが、必要な分だけ増やしてしまってください。

さて、こんなことをしたらファイルサイズがどんなことになるか想像はつくと思いますが、せっかくなのでそのままビルドしてみます。

   🚀 Building... tailwind.css

   ✅ Finished in 4.59 s
   📦 Size: 4.97MB
   💾 Saved to public/style.css

✨  Done in 6.11s.

やばいですねぇ、これはやばい。それではオチも何もなく結果も見えていますがPurgeCSSを通したビルドを行なってみます。

   🚀 Building... tailwind.css

   ✅ Finished in 4.42 s
   📦 Size: 11.79KB
   💾 Saved to public/style.css

✨  Done in 4.99s.

そういうことなんですね。Tailwind CSSの拡張は本来ならばファイルサイズ肥大とトレードオフで、神経すり減らしながら必要最小限になるよう調整しなければなりません。しかし今は令和です。我々の後ろにはPurgeCSSという対不要CSS最終防衛兵器が控えています。

ファイルサイズが2倍に膨れるようなこんな拡張を施しても、使わなかった分は全て削除されます。常に必要最小限の拡張が達成可能です。

レンダリング関数との組み合わせ

この頃流行りのライブラリと組み合わせてみます。最小手数と宣言してしまっているのでなるべく最小手数で使えそうなツールを探しました。探す手間は私が負ったので見逃してください。

サンプルコードが何の環境構築もなしに簡単に動いたので今回はPreactでいきたいと思います。まともに使った経験はないのでなんとなくで使っていきます。

preactjs.com

Getting Startedにて紹介されている最小手数っぽい方法でさきほどのTodoページを書き換えてみます。リスト部分のみです。

  <body class="p-2">
    <script type="module">
      import { h, render } from "https://unpkg.com/preact?module";

      const li = i => h(
        'li',
        { class: 'mt-2 p-2 border border-red-500' },
        [
          h('p', { class: 'border-b' }, `todo ${i}`),
          h('p', { class: 'text-sm' }, '2020 05/12'),
        ]
      )

      const app = h('div', null, [
        h('ul', { class: 'py-2 px-4 border border-red-500' }, [1, 2, 3].map(i => li(i)))
      ]);

      render(app, document.body);
    </script>
  </body>

jsxのセットアップをしだすと最小手数をはみ出しそうなのでh関数でゴリゴリと書きます。ReactしかりVueしかりElmしかり大体同じ使い心地です。結局のところTailwindCSSを使うときはclassの当て方にしか関心を持つ必要がないので、どんなツールを使おうが相性が悪くなることはないです。

f:id:robokomy:20200511200704p:plain
こんな感じ

レンダリングをscriptで制御できる利点の1つといえばリストをループでまとめて書けることです。ただループで回す欠点として、当然ですが全ての要素に同じclassが当たります。

'mt-2 p-2 border border-red-500'

そうするとこのように、端っこの要素に付けたくないmarginborderが付いてしまうことがよくあります。

f:id:robokomy:20200511200721p:plain
気になる隙間

まず浮かぶ解決策は普通にclassを付けてcssを当てていく方法です。

.item:first-child { margin-top: 0; }

しかしせっかくTailwindCSS使っているのだから、見通しをよくするためにも独自のclassは極力使いたくないという欲が出てきます。よし分かったcssを使いたくないなら、scriptで制御してしまえばよいではないか方針に切り替えます。

'p-2 border border-red-500' + (i === 0 ? '' : 'mt-2')

確かにこれで解決して世界は平和になったように見えるんですが、我々が求めているのは本当にこれだったのかという疑問が残ります。

そんなところで、TailwindCSSは新たな解を用意してくれています。

これを、

'mt-2 p-2 border border-red-500'

↓こうする。

'mt-2 p-2 border border-red-500 first:mt-0'

first:mt-0'というclassが増えました。見た通りです。これを付けると&:first-child { margin-top: 0; }と同様な効果があり、リストの先頭要素だけmargin-top0にすることができてしまいます。

f:id:robokomy:20200511201224p:plain
しゅっ

後出しですが注意点として、このfirst:mt-0という機能はデフォルト設定のままでは使えません。このようなprefixで制御するスタイル機能は複数あり、すべてを有効にすると相当なファイルサイズになってしまうからです。

tailwindcss.com

有効にするにはvariantsという設定を拡張します。firstlasthoverなど有用なものは揃っているので、気になったものはとりあえず有効にしちゃいましょう。使っていないものはどうせPurgeCSSで削除されます。

追加した設定はデフォルトのものとマージはされず、上書きされるので注意してください。

tailwind.config.js

module.exports = {
  purge: ['./public/**/*.html'],
  theme: {
    extend: {},
  },
  variants: {
    margin: ['responsive', 'first', 'last'],
  },
  plugins: [],
}

first:m-0first:m-1first:mt-0という風に用意されるclassが倍々で増えるので当然ですがcssファイルサイズも相当に膨れます。

   🚀 Building... tailwind.css

   ✅ Finished in 1.69 s
   📦 Size: 2.12MB
   💾 Saved to public/style.css

はい、PurgeCSSの出番です。

   🚀 Building... tailwind.css

   ✅ Finished in 1.44 s
   📦 Size: 11.79KB
   💾 Saved to public/style.css

レンダリング関数との組み合わせ: Elm余談

メドピアでElmは一切使われていないので完全に余談なんですが、Tailwind CSSはElmプロジェクトにものすごくおすすめです。

自分自身Elmは最近少し触っているくらいでそこまで詳しくはないという前置きをしておいて、script部分に関してはその徹底した関数型言語特性から非常に強力なんですが、スタイルに関しては重要視されていないのかあまりよい戦略が見つかりません。探せばなくもないんですが、模索中な段階だったりやたらとややこしかったりするものが多いです。

ベストプラクティスじゃなくてもいいからとにかく手軽にスタイルを当てたいんだということで、結局素のCSSファイルをindex.htmlで読み込んだり、インラインスタイルを使っていくとう場面が結構あるのではないでしょうか。そして古き良きweb開発におけるスタイル管理苦難の旅路を追体験していくわけです。

そんなあなたにTailwind CSS。

div [ class "p-2 border text-blue-500" ] [ text "hello world!" ]

ただclassを提供するだけで特定フレームワークに依存しないので、もちろんElmとも相性ばっちりです。PurgeCSSは正規表現で使われているclass名を探しているだけなので.htmlでも.jsxでも.elmでもファイル形式は問題になりません。

インラインスタイルを超えて

Tailwind CSSの使い心地はインラインスタイルライクですが、そのポテンシャルはインラインスタイル特有の制約をものともしません。先ほども紹介した、first:mt-0というprefix付きの指定方法(variants)がそれです。

インラインスタイルの泣き所として、first:hover:といったセレクタや、レスポンシブのためのメディアクエリを使うことができません。まともなwebページを作る上でこれらの制約は致命的です。仕方ないから無理な部分だけCSSを別で作って対応したとしても、今度はインラインスタイルとCSSファイル内のスタイルが散らばって管理が面倒になっていきます。

一方でTailwind CSSはインラインスタイルライクではあっても中身はセレクタ指定のCSSなので、このような制約もうまく回避してくれています。

first:hover:は先の例で既に示しました。そしてレスポンシブも同じく、xs:p-1sm:p-2という風にprefixを付けるだけで分岐が可能です。

余談: そうはいっても万能ではなかった

残念ながら万能ではありません。実際に使ってみて、これは辛いなと感じたシーンもちょくちょくあります。

例えば親要素がhoverされたら子要素をdisplay: none;にしたいなんていう場面です。1つ1つのDOMに対してclassを当てていくという使い方になるので、親子であろうとDOMを跨いだスタイル制御をすることは現状だと厳しそうです。敗北した気分でしぶしぶCSSを書きましょう。

Tailwind CSS上級者の方々ならもしかしたら解決策を持っているかもしれません。求む情報発信。

実際に導入したNuxt.jsプロジェクトでのCSS比率余談

Tailwind CSS流のclassだけで実際どこまでスタイルを作れるのかは気になる点だと思います。どうしても素のCSSを書かなければならない場面があったとしても、そういう場面が多すぎたらTailwind CSSの導入はかえってスタイル定義の散逸を招いてしまうからです。

ということでNuxt.js利用の実際のプロダクトで集計してみました。.vueという拡張子のファイル153個に対して、<styleという文字列grepでヒットしたファイルが13個です。

その13個の内容はざっと見た限りこのようなものです。

  • html全体にかかるfont-familyなどの設定
  • <slot />で挿入した要素に対してのスタイル指定
  • <select>へのappearance: none;
  • アニメーション関係(transitionanimation@keyframes
  • DOM跨ぎのhover:制御

少なくないといえば少ないかもしれませんが、前向きに捉えればこれら以外はすべてTailwind CSS流儀でカバーできているわけです。まずまずな結果ではないでしょうか。

ちなみにそのプロダクトというのはこちらです。こっそりデバッグツールで覗いてみてもらうとTailwind CSSの雰囲気が分かるかもしれません。

spot-rmc.medpeer.jp

まとめ

近年のフロントエンド関連技術は激しく進化しまくっているものの、CSS関連の話題はどうしても置き去りにされやすいです。そうはいっても辛さは無視できないので様々な手法も考案されてきてはいますが、フレームワーク依存だったりまた新たな辛さが出てきたりとなかなか明る い未来は見えてきません。

そういう状況の中で、主観ベースですが、Tailwind CSSは過去最高に使い勝手が良かったです。インラインスタイルライクなのに制約が少なく拡張性が高い、そしてCSSファイルを管理する必要がほぼない。この特徴が非常に強力です。

CSS管理は諦めてTailwind CSSを使おう。現時点で私から提示できるCSS戦略のベストプラクティスです。


これは全く余談ではないんですがメドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

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

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

■開発環境はこちら

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


中途で入社したての私からみたメドピア開発環境のいいところ

2020年1月付けで入社した社長室 エンジニアの芝田と申します。 社長室ではkakariという、かかりつけ薬局化を支援するサービスをやっており、そちらでサーバーサイドエンジニアとして働いています。

エンジニアとしてのキャリアはメドピアで2社目で、まだまだ勉強中の身です。 今回はメドピアでの開発を始めて、開発環境のいいところや実装のtipsを一部ご紹介したいと思います。

開発環境のいいところ

CIでRSpecやRubocopをはじめとする複数のLint👮が通っていないと原則マージできない

Rubyは自由な記法ができるメリットの反面で、記法のばらつきが比較的出がちです。そこは、Lintによってある程度カバーすることが可能です。

また、ClassLengthLineLengthAbcSize等によって、ファイルの肥大・コードの複雑度合いを知ることができます。 kakariではClassLength 100行以上はマスターデータではない限り許可していないので、読むのを諦めたくなるファイルは無いです。

Dangerを使い、他のファイルと合わせてこう書いてほしいというレビュー漏れを無くしている

例えば、プルリクエストを出した際に、XXX.created_at.strftime('%Y/%m/%d %H:%M')をチーム内ではl(XXX.created_at, format: :datetime_with_slash)と書いてほしい時に、Dangerで設定しておくと自動で警告を出してくれます。

API~フロントエンド間で開発前にOpenAPIを使って、送るパラメータと期待するレスポンスを決定している

yamlを書くだけでSwagger Editor上やChromeの拡張でリッチなUIのAPI仕様書を自動生成してくれたり、committeeを使って、OpenAPIで定義したレスポンスをRspecで自動チェックしてくれたりします💪

kakariのメンバーが以前作成した資料がありますので、ご興味ある方は併せて確認お願いします。

speakerdeck.com

可能な限りパフォーマンスの良い書き方を求められる。

bulletでテスト時にN+1を検知したり、無駄な繰り返し処理をできるだけ減らす書き方を求められます。

例えば、対象となる患者を探すロジックを書きたい時、account.patients.not_deleted.select do |patient|だと論理削除されていない患者全体を取得し、患者数分繰り返し回してしまい患者数が多いほどパフォーマンスが悪くなります。

この場合、wherefindメソッドを使って一気に対象患者を検索するようにすることが望ましいです。 また、関連付けされた値をキャッシュしたかったり、テーブル同士をjoinやキャッシュする必要がある場合は、eager_loadpreloadメソッドを適切に使うようにしています。

テストをしっかり書いている

入社する前の私はどちらかというとテストは書かず、ブラウザで動作確認をしていました。しかし、テストがない環境では、バージョンアップやリファクタリングが辛かったり、動作が要件通りになっているだけ(行が長かったり引数が多い)のメソッドを書きがちです。

特にレビューや仕様追加によって、コードを変更した際、常に要件通りのチェックを毎回するのか?となり、テストを書かなかった工数分が後で確認工数増加やデグレとして降りかかってきます。

社内全体でテストがしっかり書く習慣となっているため、kakariでは最新のRails6系やRuby 2.7系を使っており、他の依存ライブラリも常にアップデートされています。また、リファクタリング系のissueにはテストが既に書かれているため、書いた本人以外でも着手しやすいようになっています。

実装のtips

プレーンなRubyファイルで書かれたPOROでロジックをこまめに切り出す

私はまとまったロジックが必要になった時、モデル側にインスタンスメソッドやクラスメソッドを書いて実装しがちでした。しかし、ロジックがモデルに集中すると、関心事が入り乱れてテストし辛いコードになりがちです。

kakariではFoo::Updaterのようなクラスを切り、クラスメソッドでcallすることが多いです。-er(~する人)が呼ばれる(call)という名前だと、メソッド名に悩まされにくく、読み手側の頭にも入ってきやすいかと思います。

class Foo::Updater
  def self.call(params:)
    new(params).call
  end

  def initialize(params)
    @params = params
  end

  def call
    update
  end
  
  private
  
  attr_reader :params
  
  def update
    # 何かの処理
  end
end

POROに関しては弊社の技術顧問である@willnetさんが書いた記事もありますので、ご興味ある方は併せて確認お願いします。

tech.medpeer.co.jp

繰り返し参照されるメソッドは変数に格納する

2度目も参照されるロジックの場合、結果を変数に格納しています。初回はインスタンス変数がnilになるので、右側の式が実行されます。

2度目以降の実行では変数が使われるので、2度目以降に引数を変えて異なる結果を取得したい場合は意図しない挙動になるので注意してください。

def foo_object
  @foo_object ||= Foo::Creator.call
end

列挙型で使いたいカラムはデフォルト値をDB側で定義するのではなく、モデル側で定義する

enumerizeを使う場合、モデルにデフォルト値を持つことが可能です。メリットとして、項目が増えたときにデフォルトの値を変えたい際、migrateファイルを発行せずに済みます。またtextで指定できるので、何の値をデフォルトにしているかが明確です。

class CreateFooBars < ActiveRecord::Migration[6.0]
  def change
    create_table :foo_bars do |t|
      t.integer :age_code, default: 4
      t.timestamps
    end
  end
end

ではなく、下記のようにモデル側で定義する。

class Foo < ApplicationRecord
  extend Enumerize

  enumerize :age_code, in: {
    all: 0, older_forty: 1, older_fifty: 2, older_sixty: 3, older_sixty_five: 4,
    older_seventy: 5, older_seventy_five: 6, older_eighty: 7
  }, default: :older_sixty_five
end

名前の重複のない関連付けの参照をする

例えばモデルとしてはfoo_answerfoo_questionで示したいが、関連付けする際には、デフォルトでfoo_answer.foo_questionとなってfooが冗長です。 Railsのデフォルトのinverse_ofから外れてfoo_answer.questionfoo_question.answersとしたい時はinverse_ofを明示的に設定します。

class FooQuestion < ApplicationRecord
  has_many :answers, class_name: 'FooAnswer', dependent: :destroy,
                     foreign_key: :question_id, inverse_of: :question
end
class FooAnswer < ApplicationRecord
  belongs_to :question, class_name: 'FooQuestion', inverse_of: :answers
end

Viewでしか使わない整形用のメソッドはDecoratorに切り出す

erbやHamlで実装している箇所はDecoratorを使ってviewにロジックを直接書かないようにしています

module FooDecorator
  def full_name
    "#{last_name} #{first_name}"
  end
end

おわりに

私は今までの考え方として、スピードを優先するときはある程度汚い状態のコードがリリースされるのは仕方がないと思っていました。しかし、メドピアではビジネスのスピード感を犠牲にせず、そしてマンパワーにも頼らず、便利なライブラリで賢く仕組み化して、コードの品質を落とさない取り組みを実践しているという部分に触れられたことが、入社して良かった点の1つです。なので、このような開発環境で成長したい人にとっては、メドピアへの入社というのは良い選択肢の一つだと感じました。

今回は他の記事と比べて1つの事項を深掘りした内容ではありませんが、「お、使ってみようかな」・「メドピアの開発環境のことをもっと知りたいな」と思っていただける内容が1つでもあれば幸いです!読んでいただき、ありがとうございました。


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

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

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

■開発環境はこちら

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


ビジュアルリグレッションテストを導入した話

f:id:mpg-kazuhiro-kobayashi:20200408153648p:plain

こんにちは。フロントエンドエンジニアの小林和弘です。

Vue.js + Atomic Designでつくられたプロジェクトにビジュアルリグレッションテストを導入しました。

ビジュアルリグレッションテストでUIの安全性を高める

コンポーネントの改修、新機能の追加、ライブラリのアップデートを行う際、UIに不要な変更が入っていないか不安になることがあると思います。リファクタリングをしようにも、意図しないところでUIが壊れないか心配になります。

画面表示に関わるコードを改修するたびに、ローカル環境やステージング環境で全UIコンポーネントを確認するのは難しいです。

また、ステージング環境と本番環境を並べて変更されたUIを目視で確認するのも非現実的です。

ビジュアルリグレッションテストはその名前の通り、視覚的な回帰テストを指します。改修前後のスクリーンショットの差分を検証するためのテストです。
開発におけるUIの安全性を高め、安心してUI改善を行えるようにビジュアルリグレッションテストの導入を行いました。

使用したツール

  • reg-suit
    • ビジュアルリグレッションテストのためのテスティングツール
    • 差分レポートを作成してくれる
    • GitHubへのPull Request通知機能があり、PR毎にUI差分が見れる
  • Storybook
    • UIコンポーネントのカタログを作成する
    • 実装済のUI、UIパターンをすぐに確認できる
    • 画面上でコンポーネントの挙動把握ができる
    • アドオンが豊富でデバイスサイズ変更時のUI表示確認などができる
  • Storycap
    • Storybookから各コンポーネントのスクリーンショットを作成する
    • reg-suitと同じGitHub Organizationのreg-viz内で管理されている

Storybookの導入

まずは比較画像の元となるStorybookを導入します。
インストール用のnpm@storybook/cliが提供されているのでnpxコマンドでインストールをします。
今回はVue.jsプロジェクトに導入するのでtypeオプションにvueを設定します。

$ npx -p @storybook/cli sb init --type vue

インストール時に行われるのは

  • Storybookと依存モジュールのインストール
  • StorybookのAddonのインストール
  • npm scriptsにStorybookを実行するスクリプト追加
  • storiesディレクトリの追加
  • 設定ファイルを格納する.storybookディレクトリの追加

になります。

storiesディレクトリ内にはサンプルのStoryファイルとVueコンポーネント(Welcome, MyButton)が格納されています。

package.jsonにはstorybook, build-storybookが追加されます。

{
  "scripts": {
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  }
}

storybookを実行するとnpm scriptsに設定されたポート番号 6006 でStorybookが立ち上がります。

$ yarn storybook

f:id:mpg-kazuhiro-kobayashi:20200407121031p:plain

Storycapの導入

次にStorybookに登録されたStoryのスクリーンショットを作成して、reg-suitで画像の比較ができるようにしていきます。
スクリーンショット作成のためにStorycapをインストールします。

$ yarn add storycap --dev

次に.storybook/config.jsを作成して、StorybookのAddonとしてStorycapを登録します。
今回はVue.jsのプロジェクトなので@storybook/vueからaddDecoratorを呼び出しています。

import { addDecorator } from '@storybook/vue';
import { withScreenshot } from 'storycap';

addDecorator(withScreenshot);

package.jsonにStorycapを実行するnpm scriptsを追加します。

{
  "scripts": {
    "screenshot": "storycap --serverCmd \"start-storybook -p 6006\" http://localhost:6006"
  }
}

実際にコマンドを実行すると、__screenshots__ディレクトリにスクリーンショットが保存されます。

スクリーンショット画像はバージョン管理に含める必要がないので.gitignoreに追記してGitの管理下から外しておきます。

__screenshots__

reg-suitの導入

まずreg-suitをインストールします。

$ yarn add reg-suit --dev

次にローカルのreg-suit initコマンドを実行します。対話形式でreg-suitの設定ができます。

$ yarn reg-suit init

Plugin(s) to install (bold: recommended)
使用するプラグインを選択します。
今回は下記の3つを選択しています。


Working directory of reg-suit. => .reg
テストの結果を出力するディレクトリを指定します。デフォルトの.regディレクトリのままで問題ありません。


Append ".reg" entry to your .gitignore file. => Yes
reg-suitの出力結果はAWS s3で管理するので、Gitの管理下から外すためYesにします。


Directory contains actual images. => __screenshots__
テストに利用する画像のディレクトリを指定します。
Storycapのデフォルト値の__screenshots__を指定します。


Threshold, ranges from 0 to 1. Smaller value makes the comparison more sensitive. => 0
テストの差分比較の閾値を設定します。
厳密に差分検知をしたい場合は0を指定します。


notify-github plugin requires a client ID of reg-suit GitHub app. Open installation window in your browser => Yes
GitHub Appのreg-suitの登録を行います。
reg-suitの設定ページがブラウザで開くので、ビジュアルリグレッションを導入したいリポジトリを選択します。
Client IDをクリップボードにコピーしておきます。

f:id:mpg-kazuhiro-kobayashi:20200407164809p:plain


This repositoriy's client ID of reg-suit GitHub app => {Client ID}
GitHub AppのページでコピーしたClient IDを設定します。


Create a new S3 bucket => No
AWSにログイン済みでs3の作成権限があればYesにしてbucketの作成を行います。
権限がなかったので今回はNoで回答しました。


Existing bucket name => ***
s3のBucketが作成済みの場合、ここでbucket名を設定します。


Update configuration file => Yes
ここまで回答した設定を、設定ファイルのregconfig.jsonに反映します。


Copy sample images to working dir => No
サンプル画像のコピーは不要なのでNoで回答します。


以上でreg-suitの設定が完了です。

次にpackage.jsonのnpm scriptsにreg-suitのコマンドを追記します。

{
  "scripts": {
    "regression": "reg-suit run"
  }
}

AWS s3の権限があればローカル実行で動作します。

$ yarn regression

CircleCIの設定

GitHubのリモートブランチへpushした時に自動でテストを実行するために、CIの設定を追記します。

今回使用しているのはCircleCIです。

GitHubアカウントでCircleCIにログインして、Projectsから対象のプロジェクトを選択します。

f:id:mpg-kazuhiro-kobayashi:20200407120526p:plain

プロジェクト選択後、Project Settings > Environment VariablesからAWS s3のAccess Key IDとSecret Access Keyを設定します。

reg-suitコマンドではデフォルトでAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY変数を参照するようになっています。

別の変数名を定義したい場合(今回は仮にREGRESSION_ACCESS_KEY, REGRESSION_SECRET_KEY)、npm scriptsのregressionで変数を代入しておきましょう。

{
  "scripts": {
    "regression": "AWS_ACCESS_KEY_ID=$REGRESSION_ACCESS_KEY; AWS_SECRET_ACCESS_KEY=$REGRESSION_SECRET_KEY; reg-suit run"
  }
}

次にCircleCIの設定ファイルconfig.ymlにJOBを追加します。

jobs:
  visual_regression:
    steps:
      - checkout
      - restore_cache:
          name: Restore node_modules
          key: yarn-{{ checksum "yarn.lock" }}-{{ .Environment.CACHE_VERSION_NPM }}
      - run:
          name: Install dependencies
          command: yarn install
      - save_cache:
          name: Cache node_modules
          key: yarn-{{ checksum "yarn.lock" }}-{{ .Environment.CACHE_VERSION_NPM }}
          paths:
            - ~/workspace/node_modules
      - run:
          name: install jp fonts
          command: sudo apt-get install fonts-ipafont-gothic fonts-ipafont-mincho
      - run:
          name: screenshots
          command: yarn screenshot
      - run:
          name: regression
          command: yarn regression --quiet

デフォルトでは日本語フォントがTofuになってしまうため、IPAフォントをインストールしています。

実際に使う

実際にGitHub上でPRを作成するとCircleCI上でvisual_regressionが実行され、完了したらキャプチャ画像のような結果レポートのコメントが追加されます。

f:id:mpg-kazuhiro-kobayashi:20200407120737p:plain

this reportのリンクをクリックすると、s3にアップロードされた差分比較ができるページに遷移します。

f:id:mpg-kazuhiro-kobayashi:20200407190805p:plain f:id:mpg-kazuhiro-kobayashi:20200407190820p:plain

差分があった場合、もしくは新規でスクリーンショットが追加された場合、reg-suitのチェックが失敗します。

レビュアーがreg-suitのPRのコメントやレポートを確認して問題がないか確認します。

UI変更に問題があった場合はPR上でUIの修正を進めます。

UIの変更に問題がなければ、Approveをします。Approveするとreg-suitのチェックがパスするようになります。

まとめ

Storybook + reg-suitによるビジュアルリグレッション導入はこれで完了です。

やっていることとしては

  • StorybookでUIのカタログを作成
  • StorycapでStorybookのスクリーンショットを作成
  • reg-suitでGitのブランチ間のスクリーンショットの比較レポートを作成
  • reg-suitのGitHub AppでPR上にコメントを通知

になります。

実際に非レスポンシブなサイトを部分的にレスポンシブ対応する際にビジュアルリグレッションテストでUI破壊をいくつか検知でき、その恩恵を受けることができました。

意図しないUI破壊を防ぐためにビジュアルリグレッションテストを導入しましたが、大元となるのはStorybookに登録されたStoryです。
なのでStorybookのメンテナンスを怠るとテストが形骸化してしまいます。

Storybookはメンテナンスコストが高く、導入が難しいという意見もあります。しかしStorybookによるUIのカタログ化は、メンテナンスコストを差し引いても大きなメリットがあると考えています。
ビジュアルリグレッションテストのスクリーンショットに利用できるだけではなく、実装済みUIの再実装を未然に防いだり、Storybook上でコンポーネントの動作を確認しながらUI開発ができたり、Addonを使ってレスポンシブ表示を確認できたりと様々なメリットがあります。

この記事で皆様の安全なUI管理に少しでも貢献できれば幸いです。


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

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

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

■開発環境はこちら

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


バックグラウンドで実行するバッチ処理の改善のためSidekiq Enterpriseを導入しました🥳

こんにちは、エンジニアの森田です。 MedPeerでは、バックグラウンドで非同期に処理を実行させる方法としてSidekiqを使っておりましたが、今回Sidekiq Enterprise(Proを含む)を導入しました。

https://sidekiq.org/products/enterprise.html

今回はSidekiq Enterpriseを導入するにあたって解決したかった課題と実際の導入方法、導入後の活用事例をを紹介できればと思います!

Sidekiq Enterpriseとは?

Sidekiq Enterpriseとは、その名の通りエンタープライズ向けの機能拡張が行われた有料版のSidekiqです。(Sidekiq Enterpriseとは別にSidekiq Proもありますが、Sidekiq Enterpriseを導入するとSidekiq Proの機能も使用出来るようになります。)

ProとEnterpriseそれぞれで主に下記のような拡張がされています。

Sidekiq Pro

  • RELIABILITY
  • BATCHES

Sidekiq Enterprise

  • RATE LIMITING
  • PERIODIC JOBS
  • ENCRYPTION

詳しい機能の紹介は公式のWikiが充実しているので、興味の有る方はそちらをご確認いただくのが良いかとおもいます。

Home · mperham/sidekiq Wiki · GitHub

Sidekiq Enterprise導入に至った背景

MedPeerではバックグラウンドで実行するバッチ処理にSidekiqを使用しています。サービスの成長に伴い日々Jobは増えていて、100を超えるJob(2020/03/19現在)が実行されています。

+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Jobs                 |   4482 |   3607 |     106 |     500 |   4 |     5 |
+----------------------+--------+--------+---------+---------+-----+-------+

Jobの中には、利用者へのインセンティブ進呈に関わる等、影響が大きいものもありJobにおける処理の信頼性の担保が今まで以上に求められるようになってきました。

またデプロイによるJobへの影響も当時の問題として発生しておりました。 実行完了までに長時間かかるようなJobがあり、デプロイ時のSidekiqのプロセス再起動により実行が中断されるとJobの処理が中断され再実行しても正常に完了できず、毎日運用担当の方からメール配信のスケジュールを共有いただき、エンジニア側でデプロイタイミングがかぶらないように注意するような運用をしていました。。。

f:id:madogiwa0124:20200325115651p:plain

このような背景から解決方法を探していたところ弊社技術顧問である前島さんからSidekiq Enterpriseを導入してみてはどうかと提案いただき、導入を検討することとなりました。

結果として上記のような問題は、Sidekiq Enterpriseの導入により解決することが出来ました🎉

Sidekiq Enterpriseの導入方法

ではSidekiq Enterpriseの具体的な導入方法について書いていきます✍

Sidekiq Enterprise申し込み

導入にあたってまずは、Sidekiq Enterpriseの下記のページから申し込みを行います。

https://billing.contribsys.com/sent/new.cgi

金額は100スレッドで月額$179(2020/03/19現在)です。スレッド数が増えていく毎に金額があがっていきます💸

You can use Sidekiq Enterprise with any number of apps and processes and machines as long as their total worker thread count in production is <= the licensed amount. Development and staging environments are free and unlimited. https://github.com/mperham/sidekiq/wiki/Commercial-FAQ

この金額に影響を与えるスレッド数は本番環境で実行されているスレッド数となっているようでstagingやdevelopmentといった本番以外の環境は自由に使うことができます。

申し込みを行うとSidekiq Enterpriseのinstall時の認証に必要なキー情報がメールにて送付されます 📩

Sidekiq Enterpriseのインストール

Sidekiq Enterpriseをアプリケーションに導入する場合は下記のようにGemfileに追記します。

source "https://enterprise.contribsys.com/" do
  gem 'sidekiq-ent'
  gem 'sidekiq-pro'
end

そして下記のようにbundle configまたは環境変数に申込時に受け取った認証用のキー情報を設定します。

export BUNDLE_ENTERPRISE__CONTRIBSYS__COM=foo:bar
# or
bundle config --local enterprise.contribsys.com foo:bar

上記のGemfileと認証用のキー情報の設定が完了したらbundle installを実行することでSidekiq Enterpriseを導入することができます 🎉

※導入したい機能によっては、initializer/sidekiq.rb等で起動時に有効化する必要があるものもございますので、Wikiを読んで導入方法を確認することをおすすめします。

ActiveJobからSidekiq::Wokerへ書き換え

Sidekiq Enterpriseの全ての機能を利用するためにはActiveJobではなくSidekiq::Wokerを使用する必要があります。そのため弊社ではSidekiq Enterpriseの機能を利用したいJobまたは新規のJobについては、ActiveJobではなくSidekiq::Workerをincludeする形式で実装するようにしています。

# before
class MyJob < ApplicationJob
  queue_as :default
  
  def perform(*_args)
    # something logic
  end
end

# after
class MyJob
  include Sidekiq::Worker
  sidekiq_options queue: :default
  
  def perform(*_args)
    # something logic
  end
end

ActiveJobを継承したJobをSidekiq::Workerをincludeする形式に修正するにあたって、引数でActiveRecordのオブジェクトを受けるような形式になっている場合には注意が必要です。

The arguments you pass to perform_async must be composed of simple JSON datatypes: string, integer, float, boolean, null(nil), array and hash. https://github.com/mperham/sidekiq/wiki/Best-Practices#1-make-your-job-parameters-small-and-simple

ActiveJobは良しなに引数のオブジェクトをシリアライズしてくれますが、SidekiqではWikiに記載の通り引数の値をto_sしてRedisにエンキューする都合上、Sidekiq::Workerをincludeしたときのperform_laterに当たるpeform_asyncの引数には単純な値しか渡すことは出来ません。そのため、下記のようにuserではなくuser_idとして取得してUserのオブジェクトを取得するような形で修正が必要となります。

# before
class MyJob < ApplicationJob
  queue_as :default
  
  def perform(user:)
    # something logic
  end
end

# after
class MyJob
  include Sidekiq::Worker
  sidekiq_options queue: :default
  
  def perform(user_id:)
    user = User.find(user_id)
    # something logic
  end
end

これでSidekiq Enterpriseの機能を使う準備が整いました🎉

Sidekiq Enterpriseの活用事例

最後に弊社におけるSidekiq Enterpriseの活用事例としてReliabilityを使ったJob実行の信頼性の向上を紹介いたします。

Reliabilityを使ったJob実行の信頼性の向上

Jobにおける処理の信頼性の担保が課題としてあげられていたので、Sidekiq Proの機能であるReliabilityを使って信頼性の向上を図っています。

Reliabilityとは、Sidekiq Proの機能である信頼性向上のための機能です。クライアント側(エンキューする側)とサーバー側(デキューして実行する側)に機能が追加されています。

https://github.com/mperham/sidekiq/wiki/Reliability

弊社でも下記の2つを活用しています。

  • Redisへのenqueueに失敗した場合にメモリ上にenqueueしておいて、接続可能となったタイミングでenqueueできるReliability Client

  • Sidekiqのプロセスが停止した場合にもRedisを総なめして孤立したqueueを実行するsuper_fetch

ネットワーク障害等によりRedisに一時的に接続出来ないために失われていたJobや、実行中にSidekiqのプロセスが停止し孤立してしまったJobの実行を担保出来るようになり、信頼性を向上させることが出来ました🎉

Ent Rolling Restartsを使った安全な再起動

デプロイ時のSidekiqのリスタートによる完了までに長時間掛かるJobの中断を防ぐために、Sidekiq Enterpriseの機能であるEnt Rolling Restartsを使って安全に再起動しています。

通常のSidekiqのリスタートではTSTP+TERMを使ったリスタートを行うと思います。通常であればこの方式で安全に再起動出来るのですが、長時間完了までに掛かるJobの中断を防ぐことは出来ません。

Sidekiq EnterpriseのEnt Rolling Restartsを使うことで、長時間掛かるJobの完了を待って安全に再起動を行うことが出来ます。

There is no limit to the time it can continue running. Upon signalling a rolling restart, a new process will be started to pick up new jobs. https://github.com/mperham/sidekiq/wiki/Ent-Rolling-Restarts

具体的にはRolling Restartsを検知した際に、下記のような形で再起動が行われます。

  • 旧プロセスは新規のJobの実行を停止し、既存のJobの実行が終わったら停止する。
  • 新プロセスが起動して新規のJobの実行を開始する。

弊社でも導入の背景に記載したとおり、長時間完了までに掛かるJobがデプロイ時に中断される問題がありましたが、Ent Rolling Restartsの機能のおかげで現在はデプロイ時にJobの実行有無を確認する必要はなくなりました🎉

おわりに

Sidekiq Enterpriseについて弊社の事例をご紹介しました。もしもSidekiq Enterpriseの導入検討の一助になれば幸いです✨

運用担当とのやりとりやJob実行の信頼性の向上のための内製化コード、なども省けて金額以上のメリットを感じています!

またSidekiq Enterpriseの導入をすすめる中でSidekiqのWikiを読んだのですが、すごく充実した内容になっているので導入に関わらずSidekiqを触っている方は読んで見ると色々と役に立つ内容が多そうでした🙌

それでは👋


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

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

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

■開発環境はこちら

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


CIで稀にSegmentation faultが起きてRubyが死ぬ問題と対応

CTO室SREの@sinsokuです。

先日、弊社のCIで稀によく Segmentation fault が起きるようになりました。

f:id:sinsoku:20200313181753p:plain

_人人人人人人_
> 突然の死 <
 ̄Y^Y^Y^Y^Y ̄

調べてみた

最初は気づかなかったけど、画像の右端のダウンロードっぽいアイコンをクリックすると、実行結果のログを全文見ることができます。

[BUG] Segmentation fault at 0x000056529cd6d5e0
ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-linux]

-- Control frame information -----------------------------------------------
c:0059 p:---- s:0312 e:000311 CFUNC  :[]
c:0058 p:0016 s:0307 e:000306 METHOD /home/circleci/*******/vendor/bundle/gems/activesupport-5.2.3/lib/active_support/execution_wrapper.rb:105
c:0057 p:0004 s:0303 e:000302 METHOD /home/circleci/*******/vendor/bundle/gems/activesupport-5.2.3/lib/active_support/execution_wrapper.rb:83
c:0056 p:0008 s:0298 e:000297 METHOD /home/circleci/*******/vendor/bundle/gems/activesupport-5.2.3/lib/active_support/reloader.rb:72
c:0055 p:0011 s:0294 e:000293 BLOCK  /home/circleci/*******/vendor/bundle/gems/activejob-5.2.3/lib/active_job/railtie.rb:27 [FINISH]
c:0054 p:---- s:0289 e:000288 CFUNC  :instance_exec
c:0053 p:0145 s:0283 e:000282 BLOCK  /home/circleci/*******/vendor/bundle/gems/activesupport-5.2.3/lib/active_support/callbacks.rb:118
(途中略)
c:0008 p:0022 s:0029 e:000028 BLOCK  /home/circleci/*******/spec/jobs/conference_mail_sending_job_spec.rb:89
c:0007 p:0003 s:0026 e:000025 BLOCK  /home/circleci/*******/spec/support/multithreaded.rb:5
(以下略)

どうやらマルチスレッドに関する何かで問題が起きているっぽい。

再現した

とりあえず、手元で20回くらい実行しても稀に死ぬのは分かった。

$ for n in $(seq 1 20); do \
    bundle exec rspec spec/jobs/conference_mail_sending_job_spec.rb:96; \
  done

調べてみた

  • 100%再現させる方法が分からない
  • エラーの起きるActiveSupport::ExecutionWrapperに怪しいコードはない
  • Thread についてあまり詳しくない

調べたけど、何も分からない...

ruby-jp で相談した

f:id:sinsoku:20200313181953p:plain

相談したら、少し経ってシンプルな再現コードが見つかった。ありがたい。

f:id:sinsoku:20200313182030p:plain

その後、笹田さんが原因と100%再現するコードを投稿。(すごい)

f:id:sinsoku:20200313182043p:plain

そして、いつの間にか笹田さんが問題を解決するパッチをコミットし、Issueの登録も済んでいた。(すごい)

https://bugs.ruby-lang.org/issues/16676

どうやら、 #hash の実行中に他スレッドから同じHashを弄ると問題が起きるようです。

テストコードの修正

すでにRubyのmasterブランチでは修正されていますが、CIで2.8.0-devを使うわけにもいきません。

Rubyの新しいバージョンがリリースされるまでのワークアラウンドとして、Railsにパッチを当てる事で対応します。

まず、以下の内容で lib/patches/fix_execution_wrapper.rb を作成します。

# frozen_string_literal: true

raise('Consider removing this patch') if RUBY_VERSION != '2.6.5'

module Patches
  # Rubyが稀にSegmentation faultでエラーになる問題を修正するパッチ。
  #
  # `Thread.current` の代わりに `Thread.current.object_id` を使うこと
  # で#hashの実行時に他スレッドがテーブルを弄るのを避けます。
  #
  # 元の実装は以下を参照してください。
  # ref: https://github.com/rails/rails/blob/v6.0.2.1/activesupport/lib/active_support/execution_wrapper.rb
  #
  # ## Issue
  #
  # `#hash` can change Hash object from ar_table to st_table
  # ref: https://bugs.ruby-lang.org/issues/16676
  module FixExecutionWrapper
    def self.active?
      @active[Thread.current.object_id]
    end

    def run!
      self.class.active[Thread.current.object_id] = true
      run_callbacks(:run)
    end

    def complete!
      run_callbacks(:complete)
    ensure
      self.class.active.delete Thread.current.object_id
    end
  end
end

ActiveSupport::ExecutionWrapper.prepend(Patches::FixExecutionWrapper)

このパッチを config/environments/test.rb で読み込む。

Rails.application.configure do
  # 中略
end

require 'patches/fix_execution_wrapper'

これで本当に直るかは自信なかったのですが、1週間くらいCIの様子をみていても Segmentation fault が起きませんでした。
たぶん大丈夫です。

まとめ

自分1人で調べていても全く再現コードを作れなかったし、再現コードを見ても原因に検討もつきませんでした。

  • ruby-jpに感謝
  • 笹田さんすごい
  • ThreadとRubyは難しい

もしスレッド周りで同じ問題を踏んだとき、この記事が参考になれば幸いです。


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

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

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

■開発環境はこちら

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


Nuxt利用プロダクトでIE11と仲良くするためのE2E

フロントエンドなエンジニアの皆さま、ご機嫌いかがでしょうか。

唐突な質問ですが、Internet Explorer 11というブラウザはお好きでしょうか。勿論大好きであられるかと存じ上げます。Webの歴史をまさにその身をもって築き上げてきた由緒正しきブラウザであります。唯一無二の王道です。昨今は様々なブラウザが溢れてあそばせております。しかし所詮それは一時的なこと。やがて全人類は母なるInternet Explorer 11の元へと還っていくことでしょう。

我々が目指したこと

Internet Explorer 11(以下、IE11)を目にすること、操作すること、その他あらゆる接点を限りなく減らしつつ、プロダクトがIE11でも動作可能なことの検証と保証を行いたい。

これを成し遂げるエンジニアリング的な手段、つまりIE11環境でのE2Eテストを自動化することを目指します。

環境

自動化を行うにあたり、何をするにもまずは環境を用意しなければなりません。IE11が動く環境です。

そして早速ですがこの環境こそがIE11環境自動テスト一番の問題と言っても過言ではないでしょう。なぜならIE11が動くということは、当たり前ですがOSとしてWindowsが動いていなければなりません。Linuxディストリビューションではだめなのです。

1年前ならここで既に諦めざるを得なかったかもしれません。しかし今は既に2020年です。Windows環境に対応したCI環境は十分手に届く範囲に登場してきています。

メドピア開発部が最もお世話になっているテスト環境はCircleCIです。そしてなんと、CircleCIでもWindows環境に対応を始めているではありませんか。

circleci.com

既にWindows対応のテスト環境は目の前にあったわけです。しかし不運なことに、この事実はこの記事を執筆中に発見しました。執筆中ということは、当然ながら当初の目的は既に果たしているわけです。今ネタバレをしましたが、つまり今回採用したのは慣れ親しんだCircleCIではありません。

IE11の全てを委ねるべく採用した環境は、Github Actionsです。
なぜGithub Actionsか、とてもいい質問です。「Githubで完結する」、「GithubがMicrosoft傘下になっていた」、「社内ではまだ導入事例も少なく目新しさがあった」、そこには色々な選ぶべき理由があります。

そしてこういう理由は後付けです。最初に目に入ってしまったからです。IE11でサービスが動いていなかった障害に傷心しながら「IE11 E2E」と適当なワードで検索したら偉大な先駆者達の功績が目に入ってしまったわけです。

qiita.com

moneyforward.com

今まで不可能だと諦め続けていたことが、「できる・・できるんだ・・っ!」という確信に変わった瞬間です。あとはもう勢いでやり切らざるを得ません。そして勢いのままに開発フローに導入し、勢いの落とし前としてこうして外部発信をして既成事実としていくわけです。

プロダクト概要

昨年11月にリリースした、「MedPeerスポット×リクルートメディカルキャリア」という医師向けスポット求人マッチングサービスです。 spot-rmc.medpeer.jp

プレスリリースはこちら。 medpeer.co.jp

求人マッチングサービスという特性上、求人情報の検索、応募フォームの入力、応募リクエストの送信あたりが動作してくれないと非常に困るクリティカルな機能となってきます。

プロダクト構成

今回ターゲットとしたプロダクトは、APIを提供するRuby on Railsによるバックエンドと、フロントエンドをSSR対応で配信するNuxt.jsという2枚看板な構成です(以下、Rails、Nuxt)。

Tailwind CSSの話題に終始した前回記事と同じプロダクトなので、お時間あればこちらもご覧ください。 tech.medpeer.co.jp

フロントエンドとバックエンドの橋渡し役となるAPIは、OpenAPIに乗っ取ってスキーマ定義を行い、openapi-generatortypescript-axiosを利用して型付きのクライアントSDK化しています。

f:id:robokomy:20200221144059p:plain
3者連携

このSDKは独立したリポジトリになっていて、スキーマ更新をpushするとSDKも最新化してくれるようCIを組んでいます。
あとはフロントエンドリポジトリ側でSDKのインストールやアップデートをしていけば、型で(ある程度は)守られたSDKを通してAPIとの連携が可能です。

問題

Railsが強めなメドピア開発環境からすると、フロントエンドが完全にRailsから分離された今回のプロジェクトはなかなかに攻めた構成でした。フロントエンド分離主義者の方々もきっと満足してくださるでしょう。

かくいう私も満足していた1人だったのですが、E2Eテストという観点からするとこの構成は大きな問題を抱えていました。
Rails主導な構成であればcapybaraを利用したFeature specが大体いい感じにしてくれるみたいです。実際メドピアではこれが大活躍していて、入社当初はFeature specによるテストの充実具合に驚いた思い出があります。

しかし困ったことに、今回のフロントエンド環境はRailsの支配下にありません。こういう状況で、E2Eテストをどう行うべきかという知見が社内に全くない状態でした。

このようなマイクロサービス気味構成におけるE2Eテストのベストプラクティスを一緒に考え、議論し、実現していってくれるエンジニアをメドピア開発部では絶賛募集中です。

今、私にできること

全てがうまくいくトータルオールインワンストップE2Eソリューションの実現は残念ながら成りませんでしたが、ある程度の妥協を許せばこんな私にもできることは残されています。

今回の目的に立ち戻ります。それは、「IE11での動作検証を自動化する」ことです。IE11固有の動作検証をしたいわけです。例えばIE11固有の仕様により、ajax通信用APIを提供しているだけのRails側機能が動作しないということはあるのでしょうか?

落ち着いてください、お気持ちは分かります。確かに無いとは言い切れないかもしれません。IE11の全てを疑ってかかりたい人生を送られてきた皆様のお気持ちは十分に分かりますが、それでもときには信じることだって大事なんです。

はい、信じました。これでまずはRailsが動作検証の対象から外れました。

f:id:robokomy:20200221153357p:plain
関心範囲

Railsが関心範囲から外れてしまえばあとはもうフロントエンド原理主義者が好きにやるだけです。先ほどAPIとの連携部分は型で守られたSDK化していると紹介しました。つまりは型さえ守っていればモックに差し替えることは容易いわけです。

f:id:robokomy:20200221153739p:plain
最小の関心範囲

当初は登場人物が3人も居て途方に暮れていましたが、IE11で動作検証することを目的とするならば、Nuxtだけを検証対象にすればよいという状況に漕ぎ着けました。

Github Actions

Github Actions用ymlファイルの紹介です。
既に先人達が詳細に紹介してくれていて、特に大きな差分もないです。異なるのは、NuxtによるSSR環境を再現するために、テストランナー用のスクリプトを別途用意している点です。

gist.github.com

続いてそのテストランナー用のスクリプトの中身です。
テスト対象となるNuxtサーバーの起動と、テスト本体を実行してくれるTestcafeの起動を担当しています。
Nuxtを裏で起動したままTestcafe(後述)を起動するという制御が必要だったので、非同期な処理が書きやすいnodeスクリプトを利用しています。

Github Actions職人であればNuxtの裏起動をもっとスマートな方法で実現できるのかもしれません。メドピアではGithub Actions職人もきっと募集しています。

gist.github.com

なにやら怪しげなコメントがいくつか挿入されています。いずれも遭遇したエラーとそれを乗り越えた歴史です。

windows環境でspawnが使えない

spawnというのは非同期で外部コマンド実行してくれる関数です。
IE11を動かすGithub Actionsは当然ながらwindows環境です。普段windows環境でnodeスクリプトを実行する習慣がないので油断していました。spawncmd経由でコマンドを実行する必要があるようです。

stackoverflow.com

@nuxtjs/pwaモジュールが吐き出すエラーでジョブが落ちる

ERROR  (node:5136) DeprecationWarning: Tapable.plugin is deprecated. Use new API on .hooks instead

こういうやつです。その他環境ではエラーを吐いても特に問題なくNuxtのビルドは成功判定となってくれるのですが、なぜかGithub Actionsのジョブとして実行すると容赦なくエラー扱いで落ちてしまいました。

@nuxtjs/pwaモジュールを最新化すればエラーは消えるらしいものの、まだβバージョンということで躊躇し、nodeスクリプト内でGithub Actionsさんに気づかれないよう実行することで回避しました。

クロスブラウザでテストする場合だと毎回ビルドし直しになって効率悪いですが、今回の対象はIE11だけなのでここも割り切っていきます。

windows環境でnpmスクリプトに環境変数を渡せない

もう1件windows由来問題です。どうやらwindowsではNODE_ENV=production yarn buildのような環境変数の指定はできないらしいです。やはり普段触らない環境は知らないことだらけですね。
cross-envというパッケージを使うとさくっと指定できるようなのでnpxでさくっと使わせていただきます。

stackoverflow.com

Testcafe

環境が出揃ったところで今更ですがテストランナーの紹介です。今回採用したのはtestcafeです。

採用理由は、IE11でさくっと動いたからです。第一目的はIE11における動作検証であって、プロダクトの仕様や機能検証といった大層なものではありません。さくっと仕組みを作って多大な労力を削減する。できそうなことが分かったのですからそのまま勢いで組み上げます。

テストコード抜粋はこんな感じです。できる限りE2E専用のidclassは入れないほうがメンテは楽な気がします。文言変更で壊れたらそのときは諦めて都度直しましょう。

gist.github.com

なにやらresizeWindowという怪しげな関数があります。これはTestcafeに限った問題ではなくIE11利用のE2E全般の問題らしいですが、IE11では画面外要素はなぜか存在しない判定をされてしまうらしいです。

全盛期の彼ならその理由を躍起になって深掘っていったかもしれません。そんな彼もIE11に打ちのめされ尽くした今となっては、目の前の現実をただ受け入れるだけのマシーンです。そういうものです、目の前で起きていることは紛れもない現実なのです。

ただし開発環境のfirefoxやchromeなどで実行するときはサイズが大きすぎると怒られるので、申し訳程度に分岐を入れておきます。

成し遂げたこと

f:id:robokomy:20200221165608p:plain
動くことの証明

かくして、IE11で我々のプロダクトが動作することの検証は自動化に成功しました。E2Eのテストカバレッジは相当少なく、おそらく10%にも届いていないです。しかしながら、最も重要なトップ画面から応募完了までのルートをIE11で通過できることが自動テストで保証されている安心感は凄まじいです。

dependabotでnpmパッケージアップデートのPRが飛んできても、それがbabel関連パッケージだったとしても、自動テストが元気よく動いてIE11環境の動作検証を行ってくれています。

結局スタイルチェックで実機IE11チェックは必須なんですがこの記事が皆様のIE11ライフに少しでも貢献できましたら幸いでした。


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


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

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

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

■開発環境はこちら

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

CircleCIのYAMLを短く書けるRails Orbを作りました

11月に入社したCTO室SREの@sinsokuです。

主にやっていることはRailsアプリのレビューや開発環境の改善です。*1

  • 社内のRailsアプリを横断して浅くレビューする(8つくらい)
  • MedPeerの開発環境の改善
    • docker-compose up で30個のコンテナが起動するのを減らす
  • SwitchPointからActiveRecord v6への移行
  • CircleCIの実行時間の短縮、稀に落ちるテストの修正
  • その他の細かい改善

このうち、CircleCIについて知見が溜まったので技術ブログで紹介します。

CircleCIで気をつける点

CircleCIの実行時間を短くするにはいくつかコツがあります。

  • gemとnpmをできるだけキャッシュする
  • RSpecを並列で実行する前に assets:precompile を実行しておく
  • 各ジョブで必要なgem(もしくはnpm)だけをキャッシュから復元する
    • 例: JavaScriptのテストはnpmのキャッシュだけ復元する

ワークフローを図にするとこんな感じです。

f:id:sinsoku:20200207111113p:plain
CircleCIのジョブのワークフロー

キャッシュの仕組みについて

詳しく知りたい人はCircleCIのページ を読んで頂くとして、ここでは一番重要な 部分キャッシュ について説明します。

CircleCIのキャッシュキーには複数のキーを指定することができます。

- restore_cache:
  keys:
    # 「OSとCPUの種類」「ブランチ名」「Gemfile.lock の checksum」でキャッシュを探す
    - gem-cache-v1-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
    # 上のキーで見つからない場合、「OSとCPUの種類」「ブランチ名」でキャッシュを探す
    - gem-cache-v1-{{ arch }}-{{ .Branch }}
    # それでも見つからない場合、 `gem-cache-v1` で始まる最新のキャッシュを探す
    - gem-cache-v1

キャッシュキーを複数指定すると上から順番にキャッシュを探します。
これによりGemfile.lockが変わっても、 bundle-installで全てのgemをインストールしないで済むようになります。

キャッシュの肥大化

部分キャッシュを使っているとgemが増え続け、キャッシュのリストアに時間がかかる問題が起きます。
bundle install --clean すれば良いのですが、少しずつ遅くなるため気づき辛い問題です。

参考: bundle install には --clean を指定する (特に Circle CI では) | Born Too Late

ちなみにYarnは自動的に不要なnpmを消してくれます。🐈

Rails Orb

上記の点を気にしながら、社内のRailsアプリで横断的に対応するのは大変なので、誰でも良い感じに設定できるOrbを作りました。

github.com

実際に社内のいくつかのRailsプロジェクトに導入しています。

使い方

Orbの提供するジョブやコマンドの詳細はOrb registryを参考にしてください。

また、Rails Orbを使う前にCircleCIの設定画面で uncertified orbs を許可する必要があります。

f:id:sinsoku:20200212184310p:plain

gemやnpmのキャッシュについて

Gitリポジトリのデフォルトブランチをキャッシュキーに含めることでキャッシュヒット率を上げています。

- restore_cache:
  keys:
    - << parameters.key >>-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
    - << parameters.key >>-{{ arch }}-{{ .Branch }}
    - << parameters.key >>-{{ arch }}-{{ checksum ".git/refs/remotes/origin/HEAD" }}
    - << parameters.key >>-{{ arch }}

一般的なジョブを提供

CircleCIの設定を簡単にできるように、以下4つのジョブを提供しています。

  • rb-deps: bundle install を実行する
  • js-deps: yarn install を実行する
  • assets: assets:precompile を実行する
  • rspec: RSpecでテストを並列に実行する

新規案件で rails new した直後なら以下の設定で良い感じにCircleCIが動きます。

version: 2.1

orbs:
  rails: medpeer/rails@x.y.z

executors:
  ruby:
    docker:
      - image: &docker_ruby circleci/ruby:2.7.0-node-browsers
  ruby_with_db:
    docker:
      - image: *docker_ruby
      - image: circleci/postgres:12.1-alpine-ram
    environment:
      DATABASE_URL: 'postgres://postgres:postgres@127.0.0.1:5432'

workflows:
  rspec:
    jobs:
      - rails/rb-deps:
          executor: ruby
      - rails/js-deps:
          executor: ruby
      - rails/assets:
          executor: ruby
          requires:
            - rails/rb-deps
            - rails/js-deps
      - rails/rspec:
          executor: ruby_with_db
          db-port: '5432'
          parallelism: 4
          requires:
            - rails/assets

ジョブとコマンドを組み合わせる

Orbの提供するrb-depsジョブとbundle-installコマンドを組み合わせると、RuboCopを実行するジョブなども短く書けます。

version: 2.1

orbs:
  rails: medpeer/rails@x.y.z

executors:
  ruby:
    docker:
      - image: circleci/ruby:2.7.0-node-browsers

jobs:
  rubocop:
    executor: ruby
    steps:
      - checkout

      # Restore gems from cache
      - rails/bundle-install:
          restore_only: true

      - run: bundle exec rubocop --parallel

workflows:
  rspec:
    jobs:
      - rails/rb-deps:
          executor: ruby
      - rubocop:
          requires:
            - rails/rb-deps

まとめ

Rails Orbを使うと .circleci/config.yml の記述をかなり減らせるかなと思います。

各社でCircleCIのYAML職人をしている方、ぜひRails Orbを試してみてください。


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


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

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

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

■開発環境はこちら

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

*1:SRE所属だけど、あまりSREっぽい仕事をしていない