TECH PLAY

株式会社スタメン

株式会社スタメン の技術ブログ

234

こんにちは。CTOの松谷です。現在はCTOとTUNAG開発部部長を兼務しており、CTOとして会社全体の技術統括を行いながら、TUNAG開発部長として開発組織マネジメントを担っています。 本記事では、スタメンの創業事業である TUNAG について、プロダクトと開発体制の紹介をします。 目次 TUNAGについて 開発体制について 技術スタック アーキテクチャ 開発組織 開発組織の変遷 フィーチャーチーム 開発プロセスについて おわりに TUNAGについて TUNAG(ツナグ)は、エンゲージメント経営の実践を通じて会社の成長や成功を支援するプラットフォームです。 昨今は、外部環境の変化により、働き方にもリモートワークなどの変化が生まれ、社員それぞれの価値観も多様化しています。そんな中、リモートワークによって社員間のコミュニケーションが少なくなってしまったり、経営理念が浸透しにくくなってしまったりと、組織における課題にも変化が生じています。 スポーツにおいては、メンバーが相互に信頼しあっているチームは強いのと同じように、ビジネスにおいても経営陣と従業員、従業員間で信頼関係を築けているチームは強く、さまざまな業界において競合他社に勝り、継続的な成長を遂げている企業があります。企業における経営陣と従業員、従業員間のこの信頼関係を「エンゲージメント」と定義し、TUNAGではこのエンゲージメントの醸成を支援しています。一見すると「従業員満足度」と似ている概念に思われますが、エンゲージメントと従業員満足度は似て非なるものです。従業員満足度は外部環境の変化などで待遇や環境が悪化するとともに満足度も下がってしまいますが、エンゲージメントは外部環境に左右されず、一丸となって支え合う強い組織をつくることができます。 TUNAGは企業のエンゲージメントの構築、つまりお互いを知って理解し、信頼し合う組織を作るための社内コミュニケーションを活性化させるプロダクトです。メイン機能としては、「会社や人の今」を知るための「タイムライン機能」や人を深く知るための「プロフィール機能」、会社の方針や文化を知る「社内制度機能」が組み込まれています。 「社内制度」は、サンクスカードや社内報、1on1ミーティングなど、会社ごとの経営方針や組織課題に合わせてさまざまな形で運用されており、その種類は多岐に渡ります。TUNAGではこれらの社内制度をアプリケーション上で利用することができ、その利用結果がタイムライン上にフィード形式で投稿され、会社全体に共有することができる仕組みになっています。 弊社内でもTUNAGを利用しており、スタメンにおけるタイムラインの画面が以下のスクリーンショットになります。人や情報が自然と集まる「コーポレートリビング」のような空間をオンライン上で実現できるよう、タイムラインに会社の情報が集約される機能設計やUI設計をしているのが特長です。 また、メイン機能以外にも業務アプリケーションとして、「ビジネスチャット機能」や「ワークフロー機能」、「組織管理機能」「データ分析機能」などのさまざまな機能を備えており、従業員数1万名超のグローバル企業から十数人の企業まで規模や業種、業態を問わず、さまざまな企業の利用を支えられる大規模なSaaSアプリケーションです。 東海テレビでTUNAGが紹介されました。YouTubeで公開されているので、ぜひ見てください。 www.youtube.com 開発体制について TUNAG開発部は、「プロダクトの価値を最速でユーザーに届ける」ために最適な技術選定や開発プロセス、組織デザインを採用しています。 技術スタック TUNAGの技術スタックにおいては以下の図のように、フロントエンドは React/TypeScript、バックエンドはRuby on Rails、モバイルアプリはSwift/Kotlinを使用しています。クラウドはAWSをメインとし、一部でGCPを利用しています。 これまで開発部は、各技術領域のベストプラクティスを積極的に導入したり、フレームワークやライブラリの最新バージョンをできる限り追従したりするなど、開発効率や運用効率を上げてプロダクトの価値提供のスピードを維持する取り組みを行ってきました。 スタートアップのプロダクト成長の舞台裏とコンテナ化までの道のり Ruby on Rails 5.1から6.1へのバージョンアップをカナリアリリースしました 今後も、このベストプラクティスの導入などの取組みを継続していきたいと考えています。例えば、現状のTUNAGのフロントエンドは、RailsのViewの中にReactが埋め込まれる形式で画面が構成されているため、ReactのバージョンがRailsによって管理されていたりとメンテナンス性が悪く、モダンフロントエンドの強みをプロダクトに取り入れにくい状況になっています。今後、ユーザー体験と開発者体験の両方が、世の中のスタンダードに追従できるように、去年からフロントエンドの分離プロジェクトを開始しました。分離後のフロントエンドはNext.jsを採用し、バックエンドはAPIサーバーに徹する方向へと徐々に改善を進めています。 アーキテクチャ TUNAG本体のアーキテクチャの概要は以下になります。 Amazon ECS上のDockerコンテナで実行された、モノリシックなRailsアプリケーションを中心としたシンプルな構成になっています。データベースにはAmazon AuroraやAmazon ElastiCache、Amazon S3、そして全文検索エンジンとしてはAmazon OpenSearch Serviceを利用しています。CI/CDにはCircleCIを利用しており、RSpecやJestによる単体テスト、そしてCypressによるE2Eテストを実施した後にデプロイをしています。プロダクトのコアな価値以外のシステム(メール・SMS・プッシュ通知・画像配信基盤など)には積極的にSaaSを活用し、開発・運用コストを圧縮しています。 またTUNAGでは、アプリケーション上のユーザーのアクティビティを追跡し、各社ごとにエンゲージメントに関する数値などを集計した結果をダッシュボード機能で提供しています。この機能を支えるデータ分析基盤のアーキテクチャの概要は、以下になります。 データはすべてデータレイク(Amazon S3)で一元管理し、必要なときに必要なデータを取り出せるようにしています。またデータ処理基盤は、Amazon AthenaやAWS Glue、AWS Lambdaなどのマネージドサービスとサーバーレスアーキテクチャを組み合わせたシンプルな構成であり、特に複雑なことはしていません。クラウドの強みをちゃんと活かし、かつ今後のデータ分析における試行錯誤がしやすい、柔軟な基盤になっています。 このように、適材適所でマネージドサービスやサーバーレスアーキテクチャを採用することで運用コストをほぼゼロにし、プロダクトのコア部分に注力できるようにしています。アーキテクチャの詳しい説明は以下を参照いただければと思います。 サーバーレスのデータ分析基盤について スタメンは、TUNAGだけでなくFANTSというオンラインサロンプラットフォーム事業も展開しており、今後も新規事業を積極的に展開していきたいと考えています。 このような組織においては、組織内での車輪の再発明を防ぎ、組織化する強みを生かしていくために、横展開することを想定した設計や基盤の整備、及びナレッジの水平展開を可能にする情報共有・展開の仕組みを考えていくことが大切になります。特にTUNAGはスタメンの創業事業なので、他の事業に先行して知識を獲得することが多く、ノウハウの横展開を意識することが重要になります。それを実現するための施策の一つとして、Infrastructure as Codeによるコードの再利用によってサービス基盤やデータ分析基盤がすぐに立ち上がる状況を整備し、最速でサービスの立ち上げができるようにしています。実際に第二の事業FANTSでは、TUNAGの資産を大きく活かして素早くサービスを立ち上げることができました。 開発組織 TUNAGを運営する事業部には開発部と企画部があり、2022年5月時点で開発部には15人のエンジニアが、そして企画部にはプロダクトオーナーとプロジェクトマネージャー、デザイナーが所属しており、この2つの部署が連携してTUNAGの企画・開発を担当しています。 そしてその開発プロセスはスクラムを採用しており、以下の図のように組織は3つのフィーチャーチームと1つのモバイルチームの合計4チームで構成されています。このフィーチャーチームは機能横断型で、各スプリントで価値を生み出すために必要なすべてのスキル(バックエンド・フロントエンド)を備えています。 2022年5月時点 開発組織の変遷 TUNAG事業が成長するに従い、開発組織が直面する課題や求められる能力は、刻々と変化してきました。現在はフィーチャーチーム体制としていますが、事業の成長段階に合わせて開発組織も変化させてきました。 例えば、TUNAGリリース後の1〜2年間は、プロダクトの仮説を検証する段階だったからこそ、手段は問わずにとりあえずカタチにして素早くリリースすることが求められていたフェーズでした。少人数でお互いの作業を詳しく把握できていたため、チーム分けなどの組織化の必要性はありませんでした。 その後順調に開発組織とプロダクトの規模が大きくなり、またテクノロジートレンドが移り変わるにつれて、技術のベストプラクティスから外れたまま開発をしていくことが、プロダクトの価値向上にブレーキをかけ始めました。このフェーズでは、開発部に「高い専門性」が求められていました。バックエンド領域では、スケーラビリティの課題を解決するためにサーバレスやコンテナなどを活用してアーキテクチャを進化させ、またクライアント領域でも、エンドユーザーの体験価値を最大化できるように技術的負債の返済やフレームワークの導入を進めてきました。チーム構成をバックエンドチームやフロントエンドチームのように技術領域ごとに分け、各技術領域の学習効率を最大化して事業成長を支えました。 しかし、無事に技術的な課題を乗り越えた後には、組織のスケーラビリティの問題に直面しました。技術領域でチームを分けたことにより、1つの機能をデリバリーするまでに「チーム間の依存関係」によって開発時のコミュニケーションコストが増えてしまうことが顕著になっていきました。この組織規模が大きくなったフェーズでは、組織全体として大きな力を出せるような仕組みが求められます。そこで価値をユーザーに届けるために必要なコミュニケーションがチーム内で完結するように、すべてのスキルをもった機能横断型のチーム体制と、それを支える開発プロセス(大規模スクラムLeSS)への転換を決定しました。 フィーチャーチーム TUNAG開発部の主体となるフィーチャーチームは機能横断型で、各スプリントで価値を生み出すために必要なすべてのスキル(バックエンド・フロントエンド)を備えたチームです。そして、「ソフトウェアの価値を最速でユーザーに届ける」ために、自律したアジリティの高いチームを目指しています。 理想としては現時点での「できる」「できない」は別として、「全てのエンジニアがプロダクト全体のオーナーである」という意識を持ち、「ユーザー目線での開発」「当たり前品質を支えるSRE活動」「価値を遅延なく届けるためのプロジェクトマネジメント」「プロダクトの拡張性・スケーラビリティを見越したコーディング・アーキテクト」「ユーザーの損失を最小限に抑える障害対応」「開発体験・開発効率向上への取り組み」などを実現できるようになりたいと考えており、足りないギャップを徐々に埋めて、お互いに学び合える組織にしていこうと、モブプログラミングなどのさまざまな取り組みをしています。 その結果、今までのマネージャーやSREなどの個人のロールへ依存していた状態から、多くの機能をもつ自律したチームへと徐々に変化していきました。例えば、TUNAG開発部のエンジニアには、バックエンド領域(Ruby on Rails)とフロントエンド領域(React)の両方を担当できるメンバーが増えてきました。また、自分自身でプロジェクトマネジメントをするのは当然のこと、リリースした機能のパフォーマンスを自ら監視し、必要があれば自分でパフォーマンスチューニングをすることもしています。また、開発者がAWSなどのクラウドのアラート対応などのSRE業務をしたり、開発フェーズだけでなく運用フェーズも担当できるエンジニアの比率が大きくなってきました。 一人のエンジニアの活躍の範囲が広がれば、当然認知負荷も大きくなります。そのため、コンテナ化やサーバーレス化など、アーキテクチャのモダン化などによる運用コストの圧縮や、社内勉強会の積極的な開催やモブプログラミングによる組織学習がこのアジリティ向上の取り組みを支えています。 一方でフィーチャーチーム体制は、プロダクト開発におけるコミュニケーションを最優先することになり、技術領域ごとにおけるコミュニケーションの優先順位が下がってしまうことを意味します。ただ、そのコミュニケーションの優先順位が下がってしまうことを意識した上で、それをカバーできるような動きを取れば対処できると考えています。例えばSRE領域やフロントエンド領域などは、今まで以上に標準化の推進や分科会などの会議体設計の工夫を行っています。 また、大切な点は、プロダクト開発に最適化されたフィーチャーチームを開発組織の主体にしていきたいということであり、全てをフィーチャーチームで統一したいというわけではありません。事業をプロダクトで伸ばすためには、各技術領域のエキスパートの力が不可欠です。今後はモバイルアプリチーム含めて必要に応じて専門チームを追加していくなど、上手くバランスを取っていきたいと考えています。 開発プロセスについて 技術領域でチームを分けていた頃は、開発プロジェクトごとに必要なスキルを持ったメンバーが各チームから集められ、完了するとチームを解散するといったように「プロジェクト」を中心に開発を進めてきました。そのため、「納期」や「担当」といったようなプロジェクト自体の関心事が大きくなりがちで、プロダクト全体の価値を考える機会が多くはありませんでした。今後はプロジェクト中心ではなく、プロダクト全体における優先順位に関心を向けることで、「なぜこれが重要なのか」「誰のためなのか」といったようにプロダクト全体の価値を追求する「プロダクト文化」を醸成していきたいと考えています。 また、今までは開発タスクに個人が割り当てられていたため、「個人vs課題」という構図になりやすく、同じチームだったとしてもメンバーの開発内容や困っていることを把握しづらいという状況がありました。そのため、今後は開発タスクをチームに割り当てることで「チームvs課題」という構図に変え、より一体感を持って課題に向き合えるような「チーム文化」を醸成できる土台をつくっていきたいと考えています。 2021年冬、これらの狙いを実現できそうな開発プロセスを調査しました。プロダクト全体の価値を追求できるように、「複数チームに対して1つのプロダクトバックログで対応するフレームワークであること」、そして「シンプルなものであること」という観点で、最終的に 大規模スクラム(LeSS) を採用しました。 2022年2月から大規模スクラム(LeSS)への移行を開始したものの、開発部にはこれまでスクラム自体の経験がありません。さらに大規模スクラム(LeSS)ともなると導入難易度も高いので、社外の支援経験が豊富なスクラムコーチに協力を仰ぎ、短期間でスクラムの良さを引き出せる状態になることを目指しています。 まだまだ手探りの状態なので、大規模スクラムの導入とその後については、別途発信してお伝えできればと思います。 おわりに TUNAG開発部は「ソフトウェアの価値を最速でユーザーに届ける」という目的のために、技術スタックや開発組織、開発プロセスを柔軟に変化させてきました。先に述べましたが、事業が成長するに従い、開発組織が直面する課題や求められる能力は刻々と変化します。特にTUNAG事業のように成長が速く、変化率が高い事業ではなおさらです。その変化の中で一人ひとりのエンジニアが急成長し、これまでの事業を支えてきました。そして2022年には、さらに大きく変わっていきます。 今後も続くTUNAG開発部の転換期を一緒に楽しみ、プロダクトで事業を圧倒的に成長させていく仲間を募集しています! TUNAG開発部の技術スタックやアーキテクチャ、開発組織、開発プロセスに興味を持っていただけましたら、下記の採用ページからエントリーいただけると幸いです。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア デザイナー また、松谷の Meety も公開していますので、TUNAG開発部について気になることがあれば気軽に聞いてください😄
目次 はじめに FANTSとは 技術について 組織について 開発体制 エンジニア おわりに はじめに FANTS事業部の開発部の部長をしている田中( @sukesan_st )です。2021年9月から開発部門の部長としてチームのマネジメントをしています。去年は料理とサウナにハマった1年でした。 私の経歴を簡単に紹介しますと、以下の通りです(FANTSに関連する出来事を抜粋) 2019年4月にスタメンに入社 2020年3月〜6月にFANTS立ち上げの開発メンバーとして参画 2020年7月からFANTS提供開始 2021年1月よりFANTSの開発チームに異動し、マネージャーに就任 2021年9月よりFANTS事業部に開発部が新設され、部長に就任 本記事ではスタメン第2の事業であるFANTSについて、事業や利用している技術・開発組織の紹介をしたいと思います。 FANTSとは はじめに、FANTSについて紹介します。 FANTSは「オンラインコミュニティ運営において必要なプラットフォームシステム」と、「全体ディレクションやコミュニティ運営のコンサルティング⽀援」の2軸をワンストップで提供し、コミュニティのエンゲージメントを⾼めていくサービスです。 プラットフォームとして様々な機能を提供しており、例えばサブスクリプション型の決済システムと会員管理が連動していたり、専用アプリがあったり、タイムラインに様々な内容を投稿できたりします。 先日、運用サロン数が100サロンを突破し、サービスとしても大きく成長しています。 FANTS、運用サロン数100を突破。1年で約10倍に成長 代表的なサロンを紹介すると、以下のサロンがあります。 一覧ページ もあるので、気になる方は是非チェックしてください。 技術について FANTSのアプリケーションはTUNAGのコードを再利用しているため、基本的には弊社の創業事業である TUNAG とほぼ同じです。ここにFANTS固有の機能として、決済サービスであるStripeを利用したり、ライブ配信サービスであるVimeoを利用しています。 スタメンのエンジニア採用サイト より引用 先日TUNAGで行なったRails6.1へのバージョンアップであったり、インフラ環境をEC2からECSに移行したりといった対応がFANTSにも適応されているため、バックエンドやインフラも少しずつ昨今のトレンドに乗りつつあります。 バージョンアップやコンテナ化への道のりを綴った記事があるので、ぜひ見ていただきたいです。 Ruby on Rails 5.1から6.1へのバージョンアップをカナリアリリースしました スタートアップのプロダクト成長の舞台裏とコンテナ化までの道のり また、FANTSではオーナー様向けの機能であるダッシュボードが先日リリースされ、その中ではNext.jsを採用しています。フロントエンドの技術スタックとしては新しいものとなっているので、こちらもぜひ記事を見ていただきたいです。 FANTS ダッシュボードを支えるフロントエンド技術 そして、ダッシュボード開発を進めるにあたり、スキーマ駆動開発にて開発を進めました。開発体験としてとても良く、全体の開発効率が感覚的ではありますが上がったように思います。こちらも紹介記事を見ていただければと思います。 スキーマ駆動開発、はじめました 弊社の技術記事の紹介ばかりとなってしまいましたが、このように開発を行い、その過程や結果によって得られた知見をアウトプットして共有するのは弊社の良い文化の1つだと思っています。 組織について 2022年6月時点ではエンジニアが私含めて6名在籍しています。スタメンのエンジニアが全員で23名なので全体の約4分の1がFANTS事業部に所属している形となります。また、平均年齢が27.8歳と若手のメンバーで開発を進めています。 エンジニア FANTSの技術構成については上記でお伝えしたとおりですが、開発メンバーの職能については以下の通りとなっています。 フロントエンド: 1人 フロントエンド・バックエンド兼任: 3人 バックエンド: 2人 上記のように分かれてはいますが、フロントエンド・バックエンドの両方の開発経験もある人が5名いるため、プロジェクト状況に応じて柔軟に開発を進めています。 開発体制 現在FANTSの開発は2週間を1スプリントとしたスクラム体制で開発を進めています。開発プロジェクトが大小さまざまあるので、現時点では以下のようにスクラムイベントを行なっています。 全員参加 デイリースクラム(毎朝) スプリントレビュー(2週目の金曜日) スプリントレトロスペクティブ(2週目の金曜日) 2グループに分かれて実施 スプリントプランニング(1週目の月曜日) プロダクトバックログリファインメント(2週目の水曜日) タスク管理ツールはAsanaを、スプリントレトロスペクティブについてはMiroを使って行なっています。 以下の画像は2021/11/22〜12/3のスプリントのスプリントレトロスペクティブの様子のスクショです。スプリント内で起こった出来事と共に個人やチームの視点で各項目を振り返っています。 元々は全てのスクラムイベントを2チーム体制で行なっていたのですが、同じ開発チームなのに互いの開発状況が分からなかったり、同じサービスを作っているのに距離が遠く感じることがありました。 そこで、チームとしての一体感の醸成やお互いの情報共有の場が必要だと考え、少しずつですが一緒に行うようにしました。 結果としてはお互いの開発状況はもちろんのこと、何が良かったのか、改善すべき点は何かが全体として認識できるようになったので良かったです。 この辺り、いきなり全てのスクラムイベントを統合するのも手ではありますが、これまでよりも全体的に時間がかかるようになってしまうので、どこに時間を使うべきかと期待する結果を意識して体制を作っています。 おわりに FANTSの事業や開発技術・開発組織について、手短ではありますが紹介しました。 まだまだ少人数のチームですが、1人1人がFANTSをより良いプロダクトにするために日々開発を進めています。 一人でも多くの人に、感動を届け、幸せを広める ために、FANTSをもっと多くの人に利用されるサービスとなるように引き続き成長させていきます。 最後になりますが、この記事を見てFANTSの事業・技術・組織に興味・関心を持っていただいた方は、ぜひ下記の採用サイトからご連絡ください。一緒に FANTS を作っていきましょう! 急成長中のオンラインサロン事業を支える バックエンドエンジニアを募集! 急成長中のオンラインサロン事業を支える フロントエンドエンジニアを募集! オンラインサロン事業の長期開発インターンを募集! エンジニア採用サイト
こんにちは、株式会社スタメンで FANTS のフロントエンド開発を担当している @0906koki です。 今回の記事では、本日リリースした FANTS ダッシュボードのフロントエンド開発で選定したフレームワークやライブラリ、ディレクトリ構成について解説します。 目次 目次 FANTS ダッシュボードとは? 技術スタック Next.js SWR Styled-Components Storybook ディレクトリ構成 components apis 最後に FANTS ダッシュボードとは? FANTS ダッシュボードの説明をする前に、FANTS というプロダクトについて紹介させてください。 FANTS とはサブスク型のオンラインファンサロンプラットフォームで、オンラインファンサロンを始めたいオーナー様に、サロン開設に必要なシステム・企画等をワンストップで提供します。現状 100 サロン以上のサロンが開設されており、急成長中のサービスです。 FANTS ダッシュボードは、サロンを開設したオーナー様がサロンに関する様々な設定を行うことができる管理画面のことで、例えばサロンのロゴやユーザー管理の設定がダッシュボード上で行えます。サロンオーナー様が簡単にサロンの設定をできるよう、シンプルで分かりやすい UI・UX が重要です。 (FANTS ダッシュボードの画面UI) 元々 FANTS は、創業事業である TUNAG のコードをベースにしてスタートしたため、FANTS の管理画面は TUNAG の管理画面のコードに FANTS 特有の機能を追加する形で提供していました。 ただ、TUNAG 独自の仕様が含まれることによる分かりにくさや、フロントエンドを Rails のテンプレートである erb で実装していることによる拡張の難しさ、開発速度の低下などが課題としてあったため、今回 Next.js で新しくリニューアルするプロジェクトをスタートさせました。 バックエンド部分は、既存の Rails の基盤を用いて API と認証を実装しましたが、フロントエンドは別リポジトリで切り出して erb を Next.js で置き換えるために、1 からコンポーネントを実装し、基盤も整える必要がありました。ただ、今までの開発で感じていたフロントエンド開発での痛み(コンポーネント設計や状態管理)を解消できる形で実装することができたので、開発者体験としてはとても良かったです。 技術スタック FANTS ダッシュボードでは以下の技術スタックを使用しています。 フレームワーク : Next.js フェッチライブラリ : SWR スタイリング : Styled-Components UI 管理 : Storybook 監視 : Sentry ホスティング : Netlify Next.js 今回ダッシュボードでは Next.js をフレームワークとして選定しました。選定理由としては、技術的な観点と組織的な観点が挙げられます。 技術的な観点で言うと、Next.js を使用することでフロントエンドのベストプラクティスを実装コストを掛けずとも享受できる点にあります。例えば、コード分割や prefetch は CRA( create-react-app )でも実現可能ですが、いざ実装しようと思うとある程度コストを支払う必要があります。一方 Next.js では標準でそうした機能が付随しているので、エンジニアとしてはよりコアな機能開発に焦点を当てることが可能になります。 (その他にも、Next.js にはページごとでレンダリング方式を選択できる利点がありますが、今回のダッシュボードでは認証が必要であるのと、SEO を気にしなくて良いアプリケーションであったので、SSR や SG はせずに CSR しています。) 組織的な観点で言うと、弊社では創業当初から React をフロントエンド技術として使用しており、React に精通しているエンジニアが豊富にいます。Next.js 自体が React のフレームワークであるため、Vue フレームワークである Nuxt.js と比較して、学習コストも低く抑えることができます。 SWR SWR とは React のデータフェッチライブラリであり、サーバーデータの管理が簡単になります。SWR という名前ですが、 stale-while-revalidate という RFC 5861 で策定された HTTP の Cache-Control のオプションから来ていますが、実際に HTTP の Cache-Control を使っているわけではなく、SWR 内部でそれと似た実装がされています。 例えば、以下のようなコードがあるとします。 import { VFC } from "react" ; import useSWR from "swr" ; const fetcher = async () => { const res = await fetch ( `/api/users` ); return res ; } ; const Users: VFC = () => { const { data , error } = useSWR ( "/users" , fetcher ); if ( ! data ) return < p > loading... < /p >; if ( !! error ) return < p > error occurred < /p >; // ... } ; useSWR の第一引数で渡している key( /users )があると思いますが、SWR はこの key に対して API のデータをメモリキャッシュします。Users コンポーネントが初回マウントされたタイミングではキャッシュがないので loading fallback を表示しますが、キャッシュがある場合は loading fallback をスキップしてユーザー一覧をレンダリングすることが可能です。 loading fallback をスキップする際に、メモリキャッシュされたデータが古い場合があるので、バックグラウンドで API リクエストを送り、データが更新されていれば mutate してキャッシュも更新し再レンダリングが走ります。 弊社では今まで API のレスポンスデータを Redux で管理していましたが、非同期処理に関わるボイラープレートの実装コストや、上記のようなキャッシュ機構を実装する難しさを課題に感じ、サーバーデータの状態管理として SWR を選定しました。また、今回のアプリケーション特性上、真に管理すべきクライアントの状態は少ないことを理由に、クライアントの状態管理としては useContext + useState を使用しています。 Styled-Components Styled-Components は言わずとしれた css-in-js ライブラリです。弊社では Styled-Components を React のスタイリングとして使用してきたこともあり、今回の FANTS ダッシュボードもスタイリングツールとして選定しました。 zero runtime が売りの Linaria も検討しましたが、JS のランタイムで CSS を生成する css-in-js と比較してどれくらいパフォーマンスが変わるのか不確実だったのと、管理画面という特性上、パフォーマンスがそこまで求められないことも選定から外した理由です。 Storybook 今回のダッシュボードでは Storybook を使用して汎用コンポーネント(Button や Modal 等)を管理しています。Storybook を見ることで、すでに実装されているコンポーネントを視覚的に確認することができたり、外部環境に依存することなく Storybook 上でコンポーネントが動作するため、コンポーネントの実装がやりやすいなどの利点があります。 ホスティングは Chromatic というサービスを使用しており、CI 経由でデプロイしています。 Storybook で管理しているコンポーネントの Visual Regression Testing はまだ出来ていないので、予期せぬ変更を検知するために今後やっていきたいですね。 ディレクトリ構成 次に、FANTS ダッシュボードのディレクトリ構成について紹介します。 └── src ├── apis // Adapter層。外部データとの接続を行う ├── assets // 画像ファイルを置く ├── components │ ├── atoms // 最小のコンポーネントを配置 e.g) Button, Icon, etc... │ ├── layouts // 全ページに関わるレイアウトを規定するコンポーネントを配置 e.g) Header, Footer, etc... │ ├── molecules // 2つ以上の atoms を組み合わせたコンポーネントを配置 e.g) ActionSheet, Cropper, etc... │ ├── organisms // ドメインコンポーネントを配置 │ └── templates // ドメインコンポーネントを組み合わせてレイアウトを行う │ ├── config // SWRConfigなどの設定ファイルを配置 ├── constants // 定数ファイルを配置 ├── contexts // Context API を配置 ├── hooks // カスタム hooks を配置 ├── libs // 外部ライブラリのコンポーネントを配置する層 ├── pages // Next.js の Page コンポーネントを配置 ├── types // 型定義ファイルを配置 └── utils // 汎用的な TypeScript の関数を配置 FANTS ダッシュボードでは、上記のディレクトリ構成をしています。各ディレクトリの責務に関してはディレクトリ名の右に書かれている通りですが、依存関係を図に表すと以下の感じになります。 それぞれのディレクトリの責務に関して詳しくは説明しませんが、components と apis をピックアップして説明します。 components コンポーネントの設計では Atomic Design での命名規則に則っていますが、責務としては Atomic Design を踏襲しておらず、独自の責務をそれぞれに持たせています。そもそも Atomic Design はインターフェースにおける見た目の粒度を示すものであり、システム的な責務は規定していないためです。 Atoms と Molecules は汎用的なコンポーネントとして、抽象化されて実装されています。例えば Button コンポーネントは Atoms 配下で管理しており、様々なコンテクストから使用されるため、色やローディングの有無、ボタン内の文言は抽象化しています。 Organisms はドメインコンポーネントとして、アプリケーションとしてのコンテクストを持ちます。つまり、ユーザー管理画面のユーザーリストや、ロゴアップロード画面のフォームなど、特定の文脈で使用されるコンポーネントのことです。ここでは、hooks や context の注入を許容し、外部データへのリクエストを行う部分でもありますが、Organisms を Container コンポーネントと Presentational コンポーネントとして分割し、Container コンポーネント側で hooks や context の依存を含めるようにしています。こうすることで、依存が局所的になり変更に強くなるほか、Presentational コンポーネント側はインターフェース(props の型)にだけ依存するようになり、再利用性が高まります。 Templates は Organisms を構成するコンポーネントで、ページのレイアウトを担当し、Pages コンポーネントは Next.js でいう getStaticProps やファイルルーティングとしての責務を持ちます。 今回上記のようなコンポーネント設計で実装を進めましたが、コンポーネント粒度が少し細かすぎる点もあるため、ドメインコンポーネント(Templates と Organisms)と汎用コンポーネント(Atoms と Molecules)という 2 つのディレクトリで分けてしまっても良いかもしれません。特に Templates は Organisms コンポーネントをラップするだけのコンポーネントとなっているため、Templates と Organisms は統合しようと考えています。 apis apis のディレクトリでは、Adapter 層として API などの外部データへアクセスする責務を持ちます。例として、apis ディレクトリは以下のような構造になっています。 └── apis ├── base.ts // GETやPUTなどの、httpのベース関数を置く └── users ├── requestFetchUsers.ts // baseの関数を注入してデータにアクセスする └── usersMapper.ts // レスポンスで受け取ったデータを整形する 今回 axios を使用しているので、base では以下のように axios のインスタンスを使って、GET や POST のリクエストを行うベースの関数を管理しています。また、弊社では JSON:API の形式で API のレスポンスが返却されるので、axios の intercept を使ってデシリアライズとキャメルケースへの変換を行っています。 const axiosInstance = axios.create ( { // ... } ); axiosInstance.interceptors.response.use (async ( response ) => { return await deserializer ( response.data ); // deserializerの中でキャメルケースの変換を行っている } ); export const get = async < T >( path: string ) : Promise < T > => { try { const response = await axiosInstance. get< T >( path ); return response.data ; } catch ( e ) { const error: FetchErrorType = { status : e.response. status, } ; throw error ; } } ; // ... 以下POSTやPUTを定義 この base で定義した関数を requestFetchUsers.ts で import し、ユーザー情報を取得するロジックを書きます。基本的にエンドポイントごとにファイルを分割します。 ここで取得したデータをフロントの世界で扱いやすくするためにマッパー層である usersMapper.ts でレスポンスデータを整形しますが、レスポンスが単純な場合はマッパー層を通さずに、API レスポンスの型のまま使用します。 最後に 今回の記事では、FANTS ダッシュボードのフロントエンド技術について解説しました。 FANTS ダッシュボードは、今後もサロンオーナー様が FANTS を長く使ってもらうために様々な機能を追加していく必要があります。その過程でフロントエンドの力がますます必要になってくるので、FANTS に興味のある方は下記の採用サイトをご覧になってください。一緒に FANTS を作っていきましょう! フロントエンドエンジニア採用ページ エンジニア採用ページ ここまで読んでいただきありがとうございました!
本文 こんにちは、スタメンの松谷です。 最近、 Ruby on Railsアプリケーション環境をECSへ移行 しましたが、ログ管理には FireLens for Amazon ECS (以下FireLens)という仕組みを利用しました。 この記事ではFireLensについて説明し、実際の要件にどのように対応したのかを共有します。 FireLens とは FireLensは2019年にリリースされたECSのログ管理機構で、ECSで管理しているコンテナの loging driver に awsfirelens を指定することで、サイドカーコンテナとして Fluentd または Fluent Bit を起動し、メインコンテナからログを転送することができます。 FireLensの登場前は、ECSで管理しているコンテナの loging driver に awslogs を指定することで、標準出力を CloudWatch Logs に出力させる方法が主流でしたが、ログの種類に応じた転送先の振り分けやフィルター処理などは CloudWatch Logs側で対応する必要がありました。 FireLensを利用すれば、ECSタスク定義ファイルのみで 、Fluentd や Fluent Bit をサイドカーとして利用できます。また、独自に用意したFluentdやFluent Bitの設定ファイルを読み込むことにより、より柔軟なログのフィルターやルーティングを実現することができます。 Fluent Bit は Fluentd に比べてリソース使用量が少なく軽量なため、 AWS はFluent Bitを推奨 しています。以下は、Fluent Bitを例にして話を進めます。 ログ収集の要件 今回のログ収集の要件は以下です。 ECSタスクでは、PumaとNginxの2コンテナを定義しており、Nginxコンテナは1種類、Pumaコンテナは2種類の異なるログを出しており、それぞれ別のKinesis Firehoseエンドポイントへ転送すること Pumaコンテナ 標準出力されているPumaのログ /rails/log/access/配下のファイルに出力されているユーザーの利用ログ(JSON) Nginxコンテナ 標準出力されているNginxのログ ECSタスクが終了した際に、損失する未転送のログが最小限となるようにすること コンテナ内にファイル出力されたログを取り扱うことは、AWSが用意しているFluent Bitイメージでは対応することができないため、独自に用意したFluent Bitの設定ファイルを含めたカスタムFluent Bit イメージを利用します。 環境 今回検証した環境は以下です。 バージョン aws-for-fluent-bit 2.19.1 Fluent Bit 1.8.6 ECS コンテナエージェント 1.52.2 Docker バージョン 19.03.13-ce 実現方法 PumaコンテナとNginxコンテナの loging driver に awsfirelens を指定し、ログ収集用のサイドカーとしてFluent Bitを起動します。このサイドカーを log- routerコンテナ とします。各ログは以下の方法で収集することにしました。 NginxコンテナとPumaコンテナから標準出力されたログは、unix domain socket 経由で log-router コンテナに転送する Pumaコンテナ内でファイル出力されたログはVolume Mountを使ってホストのディレクトリをコンテナにマウントし、Pumaコンテナと log-routerコンテナ間で共有する また、損失する未転送のログが最小限とするために、 詳解 FireLens を参考に以下の対応をしました。 ECSタスクの依存関係を調整し、タスク起動時には log-router コンテナを最初に起動し、タスク終了時には最後に log-router コンテナを停止するようにする 未転送のログが最小限となるように最適化されたFluent Bit設定にすること 以下、Firelensの設定内容について説明します。 ECSタスク定義 上記を満たすECSタスク定義は以下です。(説明に不要なパラメータは省略しています) ( Fluent Bit Official Manual によると、Kinesis Data Firehoseの新プラグイン(kinesis_firehose)が推奨されていますが、ここでは旧プラグイン(firehose)を利用しています。) { " containerDefinitions ": [ { " name ": " puma ", " image ": " puma-image ", " logConfiguration ": { " logDriver ": " awsfirelens ", " options ": { " Name ": " firehose ", " region ": " ap-northeast-1 ", " delivery_stream ": " puma " } } , " mountPoints ": [ { " containerPath ": " /rails/log/access ", " sourceVolume ": " log-volume " } ] , " dependsOn ": [ { " containerName ": " log-router ", " condition ": " HEALTHY " } ] } , { " name ": " nginx ", " image ": " nginx-image ", " logConfiguration ": { " logDriver ": " awsfirelens ", " options ": { " Name ": " firehose ", " region ": " ap-northeast-1 ", " delivery_stream ": " nginx " } } , " dependsOn ": [ { " containerName ": " log-router ", " condition ": " HEALTHY " } ] } , { " name ": " log-router ", " image ": " custom-fluent-bit-image ", " healthCheck ": { " command ": [ " CMD-SHELL ", " echo '{\"health\ ": \" check \" }' | nc 127.0.0.1 8877 || exit 1 " ] } , " firelensConfiguration ": { " type ": " fluentbit ", " options ": { " config-file-type ": " file ", " config-file-value ": " /fluent_conf/fluent-bit.conf " } } , " mountPoints ": [ { " containerPath ": " /rails/log/access ", " sourceVolume ": " log-volume " } ] } ] , " volumes ": [ { " name ": " log-volume " } ] } ECSタスク定義で記述したFireLensの設定が、どのようにFluent Bitの設定に関連しているのかを確認します。上記のECSタスクを起動すると、log-routerコンテナ内の /fluent-bit/etc/fluent-bit.conf は以下のようになっています。(説明に不要なパラメータは省略しています) [INPUT] Name tcp Listen 127.0.0.1 Port 8877 Tag firelens-healthcheck [INPUT] Name forward unix_path /var/run/fluent.sock [INPUT] Name forward Listen 127.0.0.1 Port 24224 [FILTER] Name record_modifier Match * Record ecs_cluster production Record ecs_task_arn arn:aws:ecs:ap-northeast-1:${AWS::AccountId}:task/production/${ECS TaskID} Record ecs_task_definition puma-task:${revision number} @INCLUDE /fluent_conf/fluent-bit.conf [OUTPUT] Name null Match firelens-healthcheck [OUTPUT] Name firehose Match puma-firelens* delivery_stream puma region ap-northeast-1 time_key datetime [OUTPUT] Name firehose Match puma_nginx-firelens* delivery_stream puma_nginx region ap-northeast-1 time_key datetime 以下では、上記のFluent Bit設定とECSタスク定義について説明し、ECSタスク内の各種ログがどのように収集されているのかを確認していきます。 NginxとPumaの標準出力ログの設定 標準出力されたログを収集するだけであれば、FireLens で自動的に生成されるFluent Bit設定を利用するだけで済むので独自のFluent Bit 設定ファイルは必要ありません。 以下設定は、FireLens で自動的に生成される Fluent Bit設定(/fluent-bit/etc/fluent-bit.conf)の一部です。 [INPUT] Name tcp Listen 127.0.0.1 Port 8877 Tag firelens-healthcheck [INPUT] Name forward unix_path /var/run/fluent.sock [INPUT] Name forward Listen 127.0.0.1 Port 24224 ...省略 詳解 FireLens によると、 ドライバーは TCP と Unix の両方のソケットをサポートしていますが、 より高速でパフォーマンスの高いオプションである Unix ソケットを選択しました。 と記載がありますが、 /fluent-bit/etc/fluent-bit.conf の上から2番目と3番目の[INPUT]をみると、確かにFireLensに自動生成される設定では TCP と unix domain socket の両方をリッスンしていることが分かります。 log-routerコンテナ側で、unix domain socketをリッスンしていることは分かりましたが、Nginx と Pumaコンテナからは、どのようにunix domain socketへアクセスしているのでしょうか。 ECSタスクが動いているEC2インスタンスにログインして、以下コマンドで log-routerコンテナを確認します。 $ sudo docker inspect log-router # Source属性はホスト側のホストマシン側のディレクトリ # Destinationはコンテナ側のディレクトリ ...省略 { " Type " : " bind " , " Source " : " /var/lib/ecs/data/firelens/ ${taskid} /socket " , " Destination " : " /var/run " , " Mode " : "" , " RW " : true , " Propagation " : " rprivate " } , ...省略 docker inspectコマンドの結果より、ホストマシン側のディレクトリをlog-routerコンテナ側の/var/runディレクトリにマウントしていることが分かりました。これにより、他コンテナのlogging driverから/var/run/fluent.sock へアクセスすることができます。 また、Pumaコンテナの logging driver を確認すると、fluentd-address に log-routerコンテナがマウントしている unix domain socket が指定されていることも確認できます。 $ sudo docker inspect puma ...省略 " HostConfig " : { " LogConfig " : { " Type " : " fluentd " , " Config " : { " fluentd-address " : " unix:///var/lib/ecs/data/firelens/#{taskid}/socket/fluent.sock " , " fluentd-async-connect " : " true " , " fluentd-sub-second-precision " : " true " , " tag " : " puma-firelens-#{taskid} " } } , } ...省略 上記より、NginxとPumaコンテナの標準出力ログは、logging driver から unix domain socket 経由で log-router コンテナに転送されていることが分かりました。 参考: FireLens を使って fluentd logging driver 起因の fluentd の負荷を分散させる Pumaコンテナ内にファイルに出力されるログの設定 次に、Pumaコンテナ内にファイル出力しているログをどのように収集しているかを説明します。 前述のECSタスク定義 を確認すると、 log-volume というVolumeを作成し、Pumaコンテナ側でファイル出力されたログをlog-routerコンテナと共有するようにしています。このlog-routerコンテナへ共有されたファイル末尾への追記イベントを読み取るためには Tail プラグイン を利用する必要がありますが、FireLens で自動的に生成されるFluent Bit設定ファイルでは利用することができないため、独自にFluent Bit設定ファイルを用意する必要があります。 独自の設定ファイルをFireLensに指定する方法は2パターンあり、S3に置いたFluent Bit設定ファイルを指定する以下の方法("config-file-type": "s3")と { " containerDefinitions ": [ { " image ":" fluent-bit-image ", " name ":" log-router ", " firelensConfiguration ": { " type ":" fluentbit ", " options ": { " config-file-type ":" s3 ", " config-file-value ":" arn:aws:s3:::mybucket/fluent.conf " } } } ] } コンテナイメージ内またはコンテナにマウントされているボリューム上に存在するFluent Bit設定ファイルを読み込む以下の方法("config-file-type": "file")があります。 { " containerDefinitions ": [ { " image ":" fluent-bit-image ", " name ":" log-router ", " firelensConfiguration ": { " type ":" fluentbit ", " options ": { " config-file-type ": " file ", " config-file-value ": " /custom.conf " } } } ] } 現在(2021年11月時点)では、AWS Fargate でホストされるタスクは、file 設定ファイルタイプのみをサポートしているため、今後のFargateへの移行も見越して "config-file-type": "file" の方法を採用しています。 独自のFluent Bit設定ファイルは、以下のようにFluent Bitイメージ内に含めます。 FROM public.ecr.aws/aws-observability/aws-for-fluent-bit:2.19.1 ADD fluent-bit.conf /fluent_conf/ ADD parsers.conf /fluent_conf/ [SERVICE] Parsers_File /fluent_conf/parsers.conf Flush 1 Grace 30 [INPUT] Name tail Path /rails/log/access/access.log.* Tag access [FILTER] Name parser Match access Key_Name log Parser accesslog_parser [OUTPUT] Name firehose Match access region ap-northeast-1 delivery_stream accesslog また、Pumaコンテナ内でファイル出力されるログのフォーマットはJSONなので、以下のようにJSONパーサーの設定を追加し、fluent-bit.conf 側のParsers_Fileで指定します。 [PARSER] Name accesslog_parser Format json これで、Fluent Bitコンテナイメージ内に独自のFluent Bit設定ファイルを含めることができたので、ECSタスク定義のconfig-file-value属性でファイルの場所を指定すれば、/fluent-bit/etc/fluent-bit.conf 内の @INCLUDE /fluent_conf/fluent-bit.conf となっている箇所で読み込まれます。 これらにより、Pumaコンテナのファイル出力(/rails/log/access/access.log.production.*)が、log-router コンテナに共有され、Tailプラグインによってログが収集されるようになります。 ログ損失の最小化 ヘルスチェックの設定 以下は、/fluent-bit/etc/fluent-bit.conf の [INPUT] だけ切り出した内容です。 一番上の[INPUT]を見ると、port 8877 をリッスンしてFluent Bitのヘルスチェックを受け付けていることが分かります。 以下コマンドで、log-routerコンテナのヘルスチェックが可能です。 $ echo ' {"health": "check"} ' | nc 127 . 0 . 0 . 1 8877 || exit 1 ECSタスク定義のlog-routerコンテナの healthCheck属性のcommandに上記コマンドを設定し、以下のdependsOnオプションを設定すれば、log-routerコンテナが正常に起動してから、他コンテナを起動することができます。 "dependsOn": [ { "containerName": "log-router", "condition": "HEALTHY" } ] Fluent Bit のパラメータ調整 前述のECSタスク定義 のように essentialパラメータ を指定していない場合、全てのコンテナの essential パラメータは true となるため、PumaコンテナやNginxコンテナの終了はタスク終了のトリガーとなります。 ECSの StopTimeout パラメータはデフォルト値で30秒 なので、StopTimeoutパラメータの指定がない場合、ECSは30秒の猶予期間をもって、Fluent Bit コンテナを終了します。 一方で、 Fluent Bit側でGraceパラメータのデフォルト値は5秒 です。Graceパラメータの指定がない場合、Fluent Bit はデフォルトでは SIGTERM を受け取ってから 5 秒しか待機せずにシャットダウンするため、ECSタスク定義側で設定されている30秒を全て利用していないことになります。[Service]の GraceパラメータをECSタスク定義側のStopTimeout値と合わせることで、ECS側の猶予期間を最大限に活用することができます。 また、 Fluent Bit の Flushパラメータはデフォルトで5秒 なので、この値を小さくすれば転送頻度を上げることができます。 以上より、Fluent BitのGraceパラメータとFlushパラメータを調整することで、タスクが終了したときに、ログが宛先に到達する可能性を高くすることができます。 [SERVICE] Flush 1 Grace 30 参考: 詳解 FireLens まとめ 以上の設定により ログ収集の要件 を満たすことができました。FireLensを利用することで、ECSのタスク定義だけでFluent Bitのほとんどの設定が完了します。ログの扱いをカスタマイズしたい場合は、今回のように独自の設定ファイルを含んだFluent Bitイメージを用意して読み込む方法が用意されているので、様々なケースに簡単に対応できそうだと感じました。 スタメンでは、今回紹介したようなクラウド基盤の設計や構築を一緒に取り組む仲間を募集しています! 採用ページ バックエンドエンジニア インフラエンジニア
こんにちは。スタメンCTOの 松谷 です。 最近、弊社が提供している 「エンゲージメント経営プラットフォーム TUNAG」 と 「オンラインサロンプラットフォーム FANTS」 のアプリケーション環境をECS上のDockerコンテナへ移行しました。約5年間、EC2で本番運用してきたRailsアプリケーションをコンテナ化することはとても困難でリスクの高い大変な作業でしたが、今後、開発組織としてプロダクトのスケーラビリティに向き合うために必要だと判断し実行しました。 この記事では、スタメンのプロダクトの成長過程において、スケーラビリティにどのように向き合ってきたのか。なぜ今コンテナ化をしたのか。その舞台裏と移行内容を説明します。 経緯 まず、EC2で動かしていたRailsアプリケーションをAmazon ECS上のDockerコンテナへ移行するまでの経緯を説明します。 2017年 〜 2018年 (立ち上げ期) 創業事業である TUNAG は、2017年にサービス提供を開始した今年で5年目のBtoBサービスです。プロダクト立ち上げ時には、アプリケーションの実行環境として ECS という選択肢もありましたが、当時は他社の採用事例が少なく、社内にコンテナや ECS のノウハウが無かったことから、最も慣れている EC2 でRailsを動かすことを選択しました。また、サーバー構成管理ツールとして Chef Solo を導入しました。 サービス提供を開始してから2018年までは、サーバー増設の必要があれば、都度手動でEC2インスタンスを立ち上げ、Chef Solo でサーバーをプロビジョニングしていましたが、導入企業数も少なかったため、増設の頻度は少なく運用コストはほぼありませんでした。 2019年 〜 2021年 (成長期) ありがたいことに TUNAG の導入企業数が増え、様々な業種のニーズに対応するためにプロダクトの規模が大きくなってきました。また、エンタープライズ企業様の導入が進むにつれて、サーバーの負荷が一気に高まるシーンが増えていました。スケーラビリティの課題が大きくなってきたこの時期に、システムの信頼性向上に取り組むチームとして SREチーム が発足しています。スケーラビリティにおける課題を発見すれば、SREチームがアプリケーションのパフォーマンスチューニングをしたり、手動でサーバーを構築し本番環境に投入していました。ただ、SREチームとはいえインフラ専任ではなく、アプリケーション開発も並行していたため、手動でのインフラ運用コストは無視できなくなってきました。 さらに2020年に新規事業 FANTS のサービス提供が始まりました。TUNAGのスケーラビリティを確保しつつ、一方で FANTSのインフラを支えるということを少人数で実施していました。FANTSのアプリケーションがTUNAGのコードを再利用していたことから、インフラ環境もTUNAGと同じく EC2 を選択しましたが、これまでのインフラ管理方法について以下のような懸念を感じていました。 冪等性を考慮する難しさ Chef Solo の特性として冪等性というものがありますが、この冪等性を考慮してコードを管理することが難しく、様々な状況を考えないと一発でサーバー設定を完了させることができませんでした。当然、ツール自体の問題ではなく使い方の問題ですが、今後、複数人でサーバーの設定管理していくことの難しさを感じていました。サーバーを増設するたびに、Chef Solo の実行エラーに悩まされたり、他のサーバーと本当に同じ設定になっているかどうかの確認作業によって、少なくない時間を使っていました。 本番稼働中のサーバーを変更する不安 本番サーバーの設定を変更する際には、オンラインで Chef Solo を適用していました。オンラインで適用しても問題ないことを確認しているとはいえ、本番環境で稼働しているサーバーを変更することの安全性に不安を感じていました 。 一部メンバーへの負担の偏り 以下の3点の理由から、サーバー管理スキルの冗長化が遅れ、一部メンバーの負担が高い状況が長いこと続いてしまっていました。 負荷対策は緊急度が高く、他のメンバーに経験してもらう機会を作りにくいこと 作業そのものが危険なため、慣れている人が毎回実施してしまうこと 今後 Chef Solo が更新されないことが分かっており、今から学ぶモチベーションが生まれづらいこと 上記のように、各種インフラ作業の心理的ハードルは高く、プロダクトのスケーラビリティを支える上で、健全な状態ではなかったと言えます。もしコンテナのように、変更の度にアプリケーションを使い捨てにできるのであれば、冪等性の考慮も不要になり、本番で稼働中のサーバーを変更する必要もありません。また、Docker、 ECS、Kubernetesなどコンテナ関連の技術は大きなトレンドにもなっており、エンジニアが学ぶモチベーションにも繋がります。これらのコンテナの特性こそがチームが必要としていたものであったことは理解しており、過去にコンテナ化を検討したことは何度かありましたが、プロダクト開発を優先し見送ってきました。しかし、既存の運用方式は限界に近い状態だったこと、また、ちょうど近い時期に TUNAG と FANTS のRailsメジャーバージョンアップが予定されており、アプリケーション全体の動作確認を実施するのであれば、コンテナ移行のようにアプリケーション全体の動作確認が必要なリリースも一度にまとめてしまおうということで、ついに2021年の春頃にコンテナ移行プロジェクトが始まりました。 移行作業について コンテナ(ECS)移行における主要な変更点について簡単に共有します。アプリケーションのコンテナ化だけでなく、周辺のシステムアーキテクチャレベルでの変更もいくつかありました。 Chef Solo の内容を Dockerfile へ移行 まずは Chef Solo を読み解くところから始まりました。歴史的経緯などを把握しつつ、コンテナ化する上で必要なミドルウェアや設定を1つ1つ丁寧に確認していきました。そしてこれらの設定をDockerfileへ移植しました。 アプリケーションのコンテナ移行 アプリケーションで稼働していたRailsのプロセスは4種類ありました。Webアプリケーション本体のPuma、非同期処理の Sidekiq と delayed_job、EC2インスタンス上のcronから呼び出されるrakeタスクです。これらプロセスごとに起動するタスク数やスケーリングの設定は異なるため、 プロセスごとに ECS Service を分けて管理しています。各種ECS Serviceにオートスケーリングを設定することで、今後サービスの負荷が増えるにつれてアプリケーションを自動的にスケーリングしてくれる状態を実現することができました。 デプロイ方式の移行 もともとデプロイには Capistrano を利用して、SSHでリモートサーバーにアクセスし、各種プロセスを再起動してアプリケーションを更新していました。このため、sidekiq や delayed_job の更新時にはプロセスが完全に停止し、一時的にキューが詰まるという問題が発生していました。 ECS環境では、デプロイメント方式として「ローリングアップデート」と「BlueGreenデプロイメント」の2つを提供しています。 Sidekiq と delayed_job にはローリングアップデート方式を採用することで、稼働中のプロセスを完全に停止させずに徐々に新しいものに入れ替えていくことができるようになったため、キューが詰まる問題が解消しました。 Pumaについても同じくローリングアップデート方式を採用しようとしましたが、Puma をローリングアップデート方式でデプロイする場合、デプロイ途中に一定時間、新旧のコンテナが混在し、新しいコンテナへのリクエストで表示されたページから、古いコンテナへリクエストが飛ぶケースが存在するため、一度に新旧コンテナへのトラフィックを切り替えるBlueGreen方式を採用することにしました。また、BlueGreenデプロイメント方式は切り戻しが一瞬なので、今後リリース直後の不具合の影響を極小化することができるようになりました。 このようにプロセスのユースケースごとにデプロイメント方式を柔軟に変更できることはECSに移行した大きなメリットでした。 cronサーバーをECS Scheduled Taskへ移行 これまで、定期処理の管理には whenever というgemを利用し、デプロイのタイミングでcrontabを設定していました。crontabの運用では、システムの冗長化が難しく単一障害点となっていました。また、単一のcronサーバーで運用していたため、負荷分散も難しくサーバーのスペックを上げるしか負荷対策の方法がありませんでした。 ECSでは、ECS Scheduled Task という、Amazon EventBridge ルールを使用したECSタスク実行の仕組みを利用しています。このECS Scheduled Task の管理には、 elastic_whenever というgemを利用しており、これが whenever の設定ファイルである schedule.rb を読み、ECS Scheduled Taskを登録してくれます。 この移行により、単一障害点であったcronサーバーから、Amazon EventBridge というマネージドサービスに乗り換えることができ、可用性が向上しました。また、ECS Scheduled Task を利用することで各定期処理ごとにコンテナが立ち上がるため、負荷分散が自動で行われるようになり、負荷対策のために定期処理の時間帯をずらすような運用も撤廃できました。 ログ管理を AWS Firelens へ移行 もともとはEC2上で、ログを収集するための kinesisエージェント を起動し、一部アプリケーションのログをKinesis Firehoseへ転送し、S3で保存する仕組みが存在しました。ただ、kinesisエージェントが意図せず停止してしまった場合に、その期間のログが欠損してしまうという問題がありました。また、一部のログ以外のほとんどのシステムログを転送する仕組みが無く、EC2インスタンス上に保存されていました。 今回、コンテナに適したステートレスなアプリケーションにするために、全てのログをAWS Firelens というログルーター経由で外部へ転送する方式に変更しました。AWS Firelens とは、コンテナのサイドカーとして配置し、他のコンテナからはログドライバーとして使用できる仕組みです。また、ECSのタスク定義で、ログルータコンテナとアプリケーションコンテナの依存関係を定義できるので、確実にログルーターコンテナがヘルシーな状態でアプリケーションコンテナを起動することを保証することができるようになり、ログの正確性が向上しました。 勝ち取ったコンテナ環境 コンテナ化に至った背景とその移行内容について共有させていただきました。とても長く大変な作業でした。担当したエンジニアの皆さん本当にお疲れさまでした。多くの課題を乗り越えてようやく勝ち取ったコンテナ環境のおかげで、これまで多くの時間を割いてきたサーバー構成管理やインフラ増強などのSRE業務は圧縮され、プロダクト開発に集中できる状態になってきています。そして、SRE作業の安全性が高まったことから、SRE経験のないメンバーも積極的に巻き込むことができるようになり、徐々に一部メンバーへの負担の偏りも減ってきています。まだまだSREの組織化については課題が沢山ありますが、コンテナ移行をきっかけに大きく前進していきたいと考えています。 そして今後 スタメンは TUNAG と FANTS だけでなく、世の中にいくつもの事業を生み出していきたいと考えています。このように複数事業を運営する組織において、パターン化しやすい領域、つまり事業固有の事情が小さく専門性が高い領域は横断的な価値が活きてきます。今回獲得したコンテナの知識を組織のベストプラクティスとして横展開しインフラ運用効率を上げ、プロダクト開発に集中できるような体制を目指していきたいです。 最後に 少しずつですが、プロダクト開発に集中する上で在るべき姿に近づいてきました。コンテナに移行した後も多くの課題が出てくると思いますが、今後もプロダクトで事業を牽引してくために開発組織一丸となって乗り越えていきたいと思います。 スタメンの開発にはまだまだ課題がたくさんあります。スタメンに興味を持っていただけたら、下記の採用ページからエントリーいただけると幸いです。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア デザイナー また、私の Meety も公開していますので、この記事について気になることがあれば気軽に聞いてください😄 また、みなさんのチームの舞台裏の奮闘劇をぜひ聞かせてください!
こんにちは、スタメンのエンジニア、津田です。最近、弊社のサービスで、Ruby on Rails を 5.1 から 6.1 へバージョンアップした際、社内ユーザーからのリクエストのみを6.1環境へ送るカナリアリリースを実施したため、対応をまとめました。 今回は、Railsバージョンだけではなく、同時に以下のような変更を行いました。 EC2インスタンス上で動いていたRailsアプリケーションを、ECS上のDockerコンテナへ移行 capistranoによるデプロイから、ecspresso、CodeDeployを組み合わせたコンテナのデプロイへ移行 Railsのバージョンアップは徐々に行うのが本来だと思うのですが、数年間実施できておらず、メジャーバージョンはじめ、多くの更新が溜まっていました。E2E含め自動テストはかなり整備していますが、フレームワークのバージョンアップとなれば、どうしても手動で確認が必要な部分も残っています。アプリケーション全体の再確認をやるのであれば、コンテナ移行のように、アプリケーション全体の確認が必要なリリースも一度にまとめてしまいたい、ということで、かなり大掛かりな変更になってしまいました。 ただ、変更点が多いため、できる限り安全に、いつでも切り戻しを行えるよう、EC2上の Rails 5.1 と、ECS上の Rails 6.1 を並行稼動する期間を設け、各種の確認が済んでから切り替える、という手順を踏んでいます。 並行稼働中 並行稼働は一ヶ月ほど、以下の図のような形式で行いました。 構成図 一つのAWSアカウント内に、異なるRailsのバージョンを利用した、同じアプリケーションを2つ並行稼動させています。Railsのバージョンが異なる以外は、基本的に同じ挙動をするようにしています。 アプリケーションで稼働していたRailsのプロセスは4種類ありました。Webアプリケーション本体(Puma)、非同期処理のSidekiq, delayed_job、EC2インスタンス上のcronから呼び出されるrakeタスクです。 通常のユーザーへ影響を出すこと無く、社内ユーザーのリクエストから発生した処理のみを検証の対象にするため、以下のように工夫しました。 Puma すべての入り口となるPumaへのリクエスト振り分けは、Application Load Balancerのリスナールールで行いました。 確認の最初期は、HTTPヘッダーに特定の文字列が含まれている場合のみ、ECS環境へ振り分ける 2週間ほど、自社のオフィスのIPからきたリクエストのみECS環境へ振り分ける ある程度のユーザー数もいるため、負荷や、必要なリソースの確認もここで行っています。 Sidekiq, delayed_job Sidekiq, delayed_jobは、非同期ジョブの発行元となるpumaのリクエストが、社内のユーザーである場合のみ、ECS環境での動くようにしたかったため、以下のように対応しました。 Sidekiqは、ジョブの受け渡しにredisを利用しているため、ECS環境へデプロイするソースでは使用するRedisのサーバーを変更しました。ECS環境では、ジョブを登録する側(基本的にPuma上のアプリケーション)、ジョブを実行する側(sidekiqのワーカー)も、新規に設置したredisを参照します。 delayed_jobはテーブルにJobが作成されるため、Delayed::Backend::ActiveRecord::Job.table_nameを置き換え、EC2環境とECS環境で別のテーブルをジョブキューとして使用するようにしました。 Cron cronで実行されているジョブに関しては、重複して実行することが出来ないものが多く、そもそも一つのジョブが処理する範囲がユーザー単位ではないため、完全な並行稼働は出来ませんでした。一部の、重複して実行しても問題ないジョブのみ、両環境で並行稼働して挙動を確認しています。 また、ECS環境へ移行するのに伴い、常時起動するEC2インスタンスを全廃しています。OSのcronとしては実行できないため、いままで利用していた whenever gemの設定ファイルを流用できる、 elastic_whenever を一部改造して利用しました。 elastic_wheneverはCloudwatch EventsからECSのタスクを起動するのですが、ECSのCapacity ProviderにASGProviderを指定しているにもかかわらず、タスクがPendingにならず、無言で起動を諦めてしまうという問題に遭遇しました。こちらはAWSのサポートの方に相談しても原因がつかめず、Step Functions経由でリトライしてみたらどうか、というアドバイスを頂いたため、Step Functionsを呼び出すようにしています。Step Functions経由で呼び出すと、ちゃんとクラスタのオートスケールを待ってタスクが起動するため、結局リトライ自体は入れませんでしたが、各タスク起動前後に共通の処理を割り込ませたりもできるため、Step Functions経由にしたのは正解だったと思います。 アプリケーションデプロイの課題 アプリケーションデプロイは平日であれば、日に数回は行っています。Rails 6.1のアプリケーションとRails 5.1のアプリケーションは同じ機能を持つようにしましたが、コードとしては差分があり、5.1に機能が追加された際は、6.1にマージし、テストしてからデプロイする必要があるため、並行稼動に際しての問題になりました。 現環境の更新から、新環境の更新までどうしても、タイムラグが発生するため、現行の5.1をリリースした場合はALBから新環境にリクエストを流すのを自動的に停止し、新環境の6.1が現環境の5.1に追いついてから、リクエストの振り分けを再開するようにしました。ただ、マージ作業を毎回行うのは非常な手間なので、並行稼働期間中、夕方以降はリリースを基本的に停止し、その間にマージ、新環境へのデプロイを行う運用としました。 共有されるリソースへの対応 データベースはAurora MySQLを利用しており、現環境、新環境で共有しています。また、redisはsidekiq用以外にキャッシュ、ユーザーセッション用に存在しており、こちらも共有しています。基本的に同じデータを同時に両環境から扱っても問題なかったのですが、いくつか、そのままでは事前に検証したstaging環境で問題が発生したため、以下のような対応を行っています。 Aurora MySQLのクエリキャッシュ これは正確な原因がつかめなかったのですが、クエリキャッシュが有効な状態で、Rails 5.1のアプリケーションと、Rails 6.1のアプリケーションからAurora MySQLへ全く同じSQLを実行すると、クエリキャッシュが原因でエラーが発生します。 おそらく、 mysql2 gemのこのissue でレポートされているうちのいくつかと同じ問題だと思うのですが、解決できなかったため、パフォーマンスに問題が出ないのを確認した上で、Aurora MySQLのquery_cache_sizeを0としました。 rackのバージョンを固定 Rails 4.2.x から 5.0.x にアップグレードする際にカナリアリリースすると session が取得できなくなる不具合を回避する で説明されている問題が発生したため、Gemのバージョンを一時的に固定して回避しています。 CSRFトークンが異なる Rails 6.1から、CSRFトークンのエンコードが変更されました。 Upgrade-safe URL-safe CSRF tokens によって、6.0以前のCSRFトークンを持ったユーザーが、6.1環境へPOSTしてもエラーとはならないのですが、6.1以降のCSRFトークンを持ったユーザーが6.0以前の環境へPOSTすると、CSRF検証に失敗します。通常のアップグレードではこういった状況は発生しないのですが、同じユーザーからのリクエストがバージョンの異なる新・旧環境にちらばるようなカナリアリリースでは発生しえます。今回は、同じユーザーであれば基本的に同じバージョンのサーバーでリクエストが処理されるため、こちらは回避できました。リクエストの何%が新環境で処理される、というような設定だと問題になっていたと思われます。 まとめ 結局、この並行稼働期間中に発見されたアプリケーションの問題はほとんどありませんでした。しかし、ある程度の期間並行稼働することで、アプリケーションのproduction環境での挙動や、最終的な切り替えの手順、切り戻しの手順をはっきり把握でき、安心・安全のリリースが行えたのは良かったと思います。
こんにちは。株式会社スタメンの井本です。Railsによるバックエンド開発、およびSRE業務を担当しています。 弊社では頻繁に社内勉強会が開催されています。書籍をテキストとして使用するものや、ハンズオンがメインのものなど、形式は様々ですが、有志が週に一回程度時間を設けて運営しています。 最近では、バックエンド、フロントエンド、モバイルなどの それぞれの技術領域に特化した勉強会 が開催されています。 この記事では、2021年上半期に開催されたものの中から、4つピックアップしてレポートします。 目次 AWS Summit 2021 Next.js スキーマ駆動開発 クリーンアーキテクチャ AWS Summit2021 AWS Summitは一年に一回開催される、AWSのサービス、導入事例等を紹介されるカンファレンスです。今年の AWS Summit 2021 は、5/11、12に開催されましたが、セッションの動画や、使用された資料は、 セッション資料・動画一覧 で参照することができます。この勉強会では、公開されたセッションをメンバーがチェックし、それぞれ自分の興味があるセッションについて紹介する形式で実施しました。 各セッションの内容は、多くの最新情報や他社事例を含んでいるため、チームとして多くの学びを得ることができました。 例えば、弊社ではAWSのコンテナサービスの本番導入途中だったため、セッションで紹介されていた、AWSにおけるコンテナワークロードのベストプラクティスや他社のコンテナサービス導入事例が大変参考になりました。また SPA(Single Page Application)のビルドおよびデリバリーのパイプラインについても最近検討していましたが、AWS Amplify Console というフルマネージドなホスティング及びCI/CDサービスが良さそうだということで、現在本番利用に向けて検証しています。 Amazon S3 も長く使用していますが、紹介されたのセッションで初めて知った機能があり、この機能を有効化することで、弊社のクラウドコスト最適化の取り組みが前進しました。改めてカンファレンスで発表される最新情報に触れることは大切だと感じています。 来年以降も AWS Summit が開催されるたびに社内勉強会も実施していきたいと思います。 Next.js 現在、Next.jsを採用したプロジェクトが進行中ですが、事前に概要を学ぶ勉強会を行いました。 勉強会の時点では触れたことのあるエンジニアも少なかったのですが、今後、採用するフレームワークとして有力な候補であったため、テーマとして選びました。 この勉強会はハンズオン形式で進めました。公式ドキュメントに記載されているチュートリアルの内容がとても良いため、こちらを各人が進め、最終的なアウトプットとしてNext.jsを使った簡単なアプリケーションをそれぞれが作成しています。 Next.jsの土台となる部分や、多少の共通認識というのを持てたのがとても良かったと思います。 現在はプロジェクトでNext.jsを使用していますので、今後はより詳細な部分を学べる勉強会も開催する予定です。 スキーマ駆動開発 スキーマ駆動開発とは、「APIのスキーマを最初に定義し、その定義をもとにバックエンド(API)とフロントエンド(画面)の開発を進めること」を指します。新規プロジェクトにおいてスキーマ駆動開発を取り入れることで開発効率をあげるため、実際に導入することを意識して開催されました。 こちらも実際に手を動かしながら、各種ツールを使ってスキーマ駆動開発の体験し、実際に導入した際の具体的なイメージやそのメリットを体感することができました。 スキーマ駆動開発に関する知識のベースラインを揃えることが出来たため、新規のプロジェクトへの導入や運用も上手く進めることが出来ています。 スキーマ駆動開発については、別の記事にて紹介していますので、詳細が知りたい方は是非こちらの記事もご覧ください! 紹介記事: スキーマ駆動開発、はじめました クリーンアーキテクチャ 「Clean Architecture 達人に学ぶソフトウェアの構造と設計」の輪読会を行いました。この本を取り上げた理由は、アーキテクチャに対する知識が足りていないとグループメンバー全員が感じていたためです。メンテナンス性や拡張性を意識した設計・実装を行う上での前提知識を身に付けたいという思いから勉強会で取り上げることにしました。 当書ではシステムの構築・メンテナンスコストの人的コスト削減を目的として、「単一責任の原則」を始めとした設計の原則や、コンポーネント(デプロイ単位のモジュール)の凝集性(どのようにまとめるか)と依存方向、それらの考え方を基にしてどう設計すべきか?が紹介されています。 勉強会を経て、アプリケーションにおける変わりづらい部分と変わりやすい部分を分離することの重要性、それらをどう切り離すか、依存の方向性はどうあるべきか?を学ぶことができました。咀嚼できていない部分も多々ありますが、勉強会を終えて、メンテナンス性を考慮したReactのコンポーネント設計や関数の責務分離・インターフェースについてメンバーで議論するようになり、勉強会の効果を感じています。今後の機能開発やリファクタリング、ゼロからのアプリケーション構築に活かしていきます。 まとめ 勉強会のレポートは以上です。 下期においても、また新しいテーマの勉強会が始まっているので、そちらもレポートを予定しています。 最後に、スタメンではともに技術者として高め合い、良いプロダクトを作り上げていける仲間を募集しています。 ぜひ下記の採用ページからエントリーいただけると幸いです。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア デザイナー
目次 はじめに スキーマ駆動開発とは 導入背景 課題 実現したい状態 スキーマ定義 ドキュメントの閲覧 バックエンド APIのテストに利用 フロントエンド モックサーバとしての利用 導入してどのように変わったか? おわりに はじめに スタメンでエンジニアをしている 田中 です。今回は新規プロジェクトにて導入したスキーマ駆動開発について、その背景や実際にどのように開発に組み込んでいるかをご紹介いたします。 ※ 前提: スタメンではバックエンドにRuby on Rails, フロントエンドにTypeScript, Reactを利用しています。 スキーマ駆動開発とは スキーマ駆動開発とは、「APIのスキーマを最初に定義し、その定義をもとにバックエンド(API)とフロントエンド(画面)の開発を進めること」を指します。 バックエンド・フロントエンドのそれぞれの開発は定義したスキーマをベースに進めていきます。APIのスキーマを最初に定義することによって、両者の間でAPIの仕様のズレを防ぐことができます。また、仕様に変更がある場合はスキーマの定義を修正し、それぞれの開発に反映させていきます。 導入背景 課題 バックエンドとフロントエンドの開発者が別々の場合、APIのリクエストやレスポンスがどのような形式であるかについて、共通の認識を持つ必要があります。スタメンでは初めは社内wikiにて共有をしていましたが、機能が増えてきたことによってドキュメントの数も増えて管理が難しくなったり、 フォーマットが明確ではなかったので書く人によってばらつきがあるという問題がありました。 当初、まずはAPIドキュメントの自動生成およびそれに伴うフォーマットの統一から始めるために、RSpecのRequest SpecからAPIドキュメントを生成する「 rspec-openapi 」の導入を行いました。 導入記事: RSpec から API ドキュメントを生成する「rspec-openapi」を試してみた しかし、この方法ではRequest Specを作成しないとドキュメントが生成されないので、バックエンドが先行してRequest Specを記述する必要がありました。そのため、フロントエンド開発者に対しては タスクの着手時期を調整する(Request Spec記述後に着手してもらうようにする) 別途社内wikiにてドキュメントを作って共有する のどちらかをしなければなりませんでした。 実現したい状態 大きく分けると以下の3つがありました。 何らかの定義をすることで自動でドキュメントが生成される(手作業でドキュメントを作成しない) ドキュメントと実装の一致 バックエンドとフロントエンドの開発を並行で進められる 1はRequest Specとrspec-openapiにより実現できていましたが、2はバックエンドのみ、3は実現できておりません。 そこで、今回はこれらの状態の実現するために、スキーマ駆動開発を導入してみました。 以下に、実現するにあたり導入したフォーマットやツール、ライブラリを紹介します。 スキーマ定義 利用するフォーマットとしては OpenAPI Specification (以降、OpenAPI)となります。OpenAPIはRESTful APIのインターフェースを定義することができ、また、APIを表示するためのドキュメント生成ツール、様々なプログラミング言語でサーバやクライアントを生成するコード生成ツール、テストツールなど、様々なケースで使用することができます。 以下、記述例です。 paths : /users : post : summary : Adds a new user requestBody : content : application/json : schema : # Request body contents type : object properties : id : type : integer name : type : string example : # Sample object id : 10 name : Jessica Smith responses : '200' : description : OK OpenAPIはYAML形式で記述できるのですが、上記のような形式を直接編集して定義することはなかなか大変です。そこで、 Stoplight Studio というOpenAPIをGUI上で編集することが出来るエディタを利用しています。 また、ここで定義したスキーマはjsonファイルやyamlファイルとしてエクスポート出来るので、後述するAPIのテストやモックサーバに利用します。 ドキュメントの閲覧 上記に記述したStoplight Studioを利用しています。現状、開発メンバーは利用できるので、編集と閲覧をStoplight Studio上で完結させています。 バックエンド APIのテストに利用 committee 、 committee-rails というgem、定義したスキーマを利用して、APIのリクエストおよびレスポンスの検証を行うことが出来ます。 設定ファイルに下記を加えます。 include Committee :: Rails :: Test :: Methods def committee_options @committee_options ||= { schema_path : Rails .root.join( ' doc ' , ' openapi.yaml ' ).to_s, } end そして、検証したいリクエストまたはレスポンスに対して、 assert_request_schema_confirm や assert_response_schema_confirm(200) にてチェックすることが出来ます。 describe ' GET / ' do it ' conform yaml schema ' do get ' / ' assert_request_schema_confirm # リクエストのチェック assert_response_schema_confirm( 200 ) # レスポンスのチェック end end 仮にエラーが発生すると例としてこのようなメッセージが返ってきます。例では型チェックを行なっていますが、他にも必須チェックなど、OpenAPIに定義した内容に沿って確認してくれます。 Committee :: InvalidResponse : #/components/responses/products-list/content/application~1json/schema/properties/data/items/properties/attributes/properties/status expected string, but received Integer: 1 フロントエンド モックサーバの利用 Stoplight Prism と定義したスキーマを利用し、モックサーバを立てることが出来ます。Stoplight PrismはOpenAPIのスキーマをベースにしたモックサーバを立てるためのパッケージです。スキーマに定義されているリクエストボディのバリデーションやサンプルデータのレスポンスを行うことが出来ます。 Docker Image も存在するので、Docker環境で立ち上げることも出来ます。 以下、 docker-compose.yml の記述例です。 mock-server: image: stoplight/prism:4 container_name: 'mock-server' ports: - '3000:10083' command: mock -h 0.0.0.0 -p 10083 /doc/openapi.yaml volumes: - ./doc/openapi.yaml:/doc/openapi.yaml これによって、APIの開発を待つことなくフロントエンドの開発を進めることが出来るようになります。 導入してどのように変わったか? 冒頭にあげた実現したい状態について、どのように変わったかを説明します。 何らかの定義をすることで自動でドキュメントが生成される(手作業でドキュメントを作成しない) Stoplight StudioでOpenAPIを定義すると自動的にドキュメントとしても利用ができるようになりました。 ドキュメントと実装の一致 定義したスキーマを利用することでバックエンドではAPIのテストによるチェック、フロントエンドではStoplight Prismのバリデーションやレスポンスによってチェックができています。 バックエンドとフロントエンドの開発を並行で進められる フロントエンドについてはStoplight Prismのモックサーバを用いた開発により、これまでのバックエンド先行の開発ではなく、並行して開発が進められるようになりました。 スキーマ駆動開発を導入することで実現したい状態をそれぞれ満たすことが出来るようになりました! しかし、まだ活用できていないこともあります。たとえば、 OpenAPI generatorを用いたTypeScriptの型定義 Stoplight PrismとCypressを利用したE2Eテストの実現 など、この辺りもまずは試してみて良ければ導入しようと思います。 おわりに スキーマ駆動開発をはじめるにあたり、その背景や導入したツール・ライブラリを紹介しました。導入して1ヶ月半ほどになりますが、全体の開発効率が良くなった実感があります。 また、実際の導入にあたり、プロジェクト開始前にスキーマ駆動開発の社内向けのドキュメントを作ったり、勉強会を開催したりすることで、プロジェクトに参加するエンジニアにスキーマ駆動開発の進め方やそのメリットを理解してもらい、プロジェクト内で利用するイメージを持ってもらいました。今の所は上手く運用できていますが、メンバーが増えるタイミング等でも参加メンバーが共通の認識が持てるように、今後も運用していきたいと思います。 スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページをご覧ください。 スタメン エンジニア採用サイト TUNAG インフラエンジニア募集ページ バックエンドエンジニア募集ページ フロントエンドエンジニア募集ページ モバイルアプリエンジニア募集ページ FANTS バックエンドエンジニア募集ページ フロントエンドエンジニア募集ページ
はじめに はじめまして、株式会社スタメンのミツモトです。 普段は、REST API をベースに React + Redux で開発しています。 最近になり開発者体験の向上、プロジェクトのスピードアップという観点から、社内でスキーマファーストな開発に関心が集まっています。そういった背景もあり弊社でも GraphQL の勉強会を始めました。今後のプロジェクトにおいて GraphQL を導入する可能性があるため皆で学んでいます。 React ✕ GraphQL という技術を採用する場合、client 側の状態管理ライブラリとして Apollo Client が挙げられます。 API のレスポンスを正規化かつキャッシュしてくれる Apollo Client の inMemoryCache の恩恵は大きく、開発者だけでなくユーザーにおいても画面の表示速度向上などのメリットがあります。 Apollo Client で API のレスポンスを状態管理する一方で、client 側だけで状態を持ち、どのコンポーネントからも参照・更新したいというニーズもあるかと思います。 Apollo Client 3から Reactive variables という機能が追加され、local state 管理が簡単にできるようになりました。 今回はその話をさせていただきます。 Reactive variablesとは Reactive variables は Apollo Client のキャッシュとは別で local state を管理するのに役立つ仕組みです。cacheと分かれていることにより、どんな型・構造のデータも保持することができ、 アプリケーションのどこからでも参照・更新ができます。 参考: Reactive variables コンポーネント内で閉じる local state であれば React 公式の useState を利用できますが、状態を複数のコンポーネントで利用したい場合に別の状態管理方法を考える必要があります。そんな時に Reactive variables が利用できます。 実装方法 アプリケーション全体にかかわるテーマ(theme)を設定・反映する実装方法を例にご紹介します。 1. Reactive variables の定義 まず始めに themeVar という Reactive variables を定義します。 import { makeVar } from "@apollo/client" ; export const initialTheme = 'light' ; export const themeVar = makeVar ( initialTheme ); 以下のように値を更新できます。 themeVar ( 'dark' ); console.log ( themeVar ()) // => 'dark' themeVar() でその時の状態を呼び出せますが、コンポーネントの再描画を行うため useQuery または useReactiveVar を用います。(後述します) 2. typePolicy、typeDefs の設定 GraphQLのクエリとして theme を扱うため、 ApolloClient インスタンスのオプションに typePolicy(inMemoryCacheの引数)、typeDefs を設定します。 export const typePolicies = { typePolicies: { Query: { fields: { theme: { read () { return themeVar (); }} // Query の theme フィールドとして themeVar を返す } } } } ; import { gql } from "@apollo/client" ; export const typeDefs = gql ` extend type Query { theme: String } ` import React from "react" ; import ReactDOM from "react-dom" ; import { ApolloClient , InMemoryCache , ApolloProvider } from "@apollo/client" ; import { typePolicies } from "./typePolicy" ; import { typeDefs } from "./typeDefs" ; import App from "./App" ; const client = new ApolloClient ( { cache: new InMemoryCache ( typePolicies ), connectToDevTools: true , typeDefs , } ); ReactDOM.render ( < ApolloProvider client = { client } > < App / > < /ApolloProvider >, document . getElementById ( 'root' ) ); 3. コンポーネントでの呼び出し theme の表示確認用に ThemeView コンポーネントを定義します。 useQueryを用いて状態を参照するため、合わせてクエリを定義しています。 import React , { FC } from "react" ; import styled from "styled-components" ; import { gql , useQuery } from "@apollo/client" ; export const GET_THEME = gql ` query getTheme { theme @client } ` ; // themeの表示確認用コンポーネント const ThemeView: FC = () => { const { theme } = useQuery ( GET_THEME ) .data ; return ( < Div theme = { theme } > { theme } < /Div > ); } ; const Div = styled.div < { theme: string } > ` background-color: ${props => props.theme === 'light' ? 'white' : 'darkgray' }; ` ; themeを切り替えるための ThemeSelectコンポーネントを定義します。 import React , { FC , ChangeEventHandler } from "react" ; import { themeVar } from "../themeVar" ; // themeを切り替えるコンポーネント const ThemeSelect: FC = () => { const handleChangeTheme: ChangeEventHandler < HTMLSelectElement > = ( e ) => { themeVar ( e. target .value ); } ; return ( < select onChange = { handleChangeTheme } > < option value = "light" > light < /option > < option value = "dark" > dark < /option > < /select > ); } ; プルダウンで theme を選択することで、表示確認用のThemeViewコンポーネントの色を変更できます。 このように useQuery を用いて状態を取得できますが、useReactiveVar を利用すればよりシンプルに書くことができます。 4.【番外編】useReactiveVarを用いた実装 useReactiveVar は Apollo Client 3.2 から導入されました。こちらを利用すると GraphQLのクエリを書かずに状態を参照できます。 useQueryで呼び出していた部分が、useReactiveVar に置き換わります。 import React , { FC } from "react" ; import styled from "styled-components" ; import { useReactiveVar } from "@apollo/client" ; import { themeVar } from "../themeVar" ; // themeの表示確認用コンポーネント const ThemeView: FC = () => { const theme = useReactiveVar ( themeVar ); return ( < Div theme = { theme } > { theme } < /Div > ); } ; ... useReactiveVar を利用すると、ApolloClientインスタンスの typePolicy、typeDefs の設定も不要になります。シンプルに書けるようになりますが、デメリットとして Apollo Client Devtools で状態を確認できなくなることが挙げられます。( 確認方法を知っている方、是非教えてほしいです!) おわりに 今回の記事では Apollo Client で local state 管理をする方法として Reactive variables をご紹介させていただきました。 アプリケーション全体にかかわるテーマの例を挙げましたが、それ以外のUIに関わる状態であったり、ドラッグ&ドロップでコンポーネント間を跨ぐ処理を実装する際に Reactive variables は利用できます。気になった方はぜひお試しください! スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。 スタメン エンジニア採用サイト インフラエンジニア募集ページ サーバーサイドエンジニア募集ページ フロントエンドエンジニア募集ページ モバイルアプリエンジニア募集ページ デザイナー募集ページ
こんにちは。フロントエンドエンジニアの 渡邉 です。 普段はReactとTypeScriptを書いています。 今回は自分がNext.js + NextAuth.js + Prismaを使って認証付きアプリケーションを作成する際の土台を紹介をしようと思います。 フロントエンドエンジニアとしてトレンドの技術を抑えておきたいというのと、実際に新規のプロジェクトで開発する際に採用される可能性もあるので、Next.js + NextAuth.js + Prismaといった選定をしています。 技術の概要 Framework: Next.js Database / ORM: Prisma Authentication: NextAuth.js これらの技術を使い実際にアプリケーションを作りながら紹介していきます。 とりあえず最終的なコードが見たい方は こちら をご覧ください。 アプリ作成 TypeScriptでアプリを作成したいので --typescript フラグをつけています。 npx create-next-app --typescript sample-app cd sample-app 必要なパッケージのインストール Prisma npm install @prisma/client npm install prisma --save-dev Prismaのバージョンが2.14以降だと、TypeScript 4.1以上でないと動かないので、該当する場合はTypeScriptのバージョンをあげる必要があります。 Bug: Prisma 2.17.0: Type errors in generated client for UnwrapTuple #5726 NextAuth.js npm install next-auth @next-auth/prisma-adapter アプリケーションにNextAuth.jsとPrismaを組み込む アプリケーションにNextAuth.jsを追加するため、 pages/api/auth の中に [...nextauth].ts を作成します。 [...nextauth].ts 内は以下のように記述します。 import NextAuth from "next-auth" import Providers from "next-auth/providers" import { PrismaAdapter } from "@next-auth/prisma-adapter" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient () export default NextAuth ( { // 1つ以上の認証プロバイダーを構成 providers: [ Providers.Google ( { clientId: process.env.GOOGLE_CLIENT_ID , clientSecret: process.env.GOOGLE_CLIENT_SECRET , } ), ] , adapter: PrismaAdapter ( prisma ), } ) NextAuthのオプションとしてプロバイダー、アダプター(今回は Prisma )を設定します。これによりNextAuth.jsとPrismaを連携して、アクションがあった際にPrisma経由でユーザーをデータベースに保存しています。 プロバイダーに関しては今回はGoogleを指定し、認証機能を作成しています。 他にも様々な認証プロバイダーを指定できるので使いたいプロバイダーに変えてもらっても構いません。 次に .env.local を作成し、中身にGOOGLE_CLIENT_IDとGOOGLE_CLIENT_SECRETを記載します。 GOOGLE_CLIENT_ID=xxxxxx GOOGLE_CLIENT_SECRET=yyyyyyy client idとsecretは https://console.developers.google.com/apis/credentials で作成・取得できます。 ローカルで動作の確認をするために承認済みのリダイレクトURIは http://localhost:3000/api/auth/callback/google と設定しておいてください。 次に、以下のコマンドを実行しPrismaを初期化します。 npx prisma init .env と /prisma/prisma/schema.prisma が作成されます。 prisma/schema.prisma 内は以下のように記述します。 generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = "file:./dev.db" } model Account { id String @id @default(cuid()) userId String providerType String providerId String providerAccountId String refreshToken String? accessToken String? accessTokenExpires DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) @@unique([providerId, providerAccountId]) } model Session { id String @id @default(cuid()) userId String expires DateTime sessionToken String @unique accessToken String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accounts Account[] sessions Session[] } model VerificationRequest { id String @id @default(cuid()) identifier String token String @unique expires DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([identifier, token]) } 今回はローカル開発環境で実装しているため、sqliteを採用しています。 他のデータベースを使いたい方は、 こちら をご覧ください。 スキーマファイルを作成したら、PrismaCLIを使いPrisma Clientを生成します。 npx prisma generate 最後に次のコマンドを実行し、今回記載したスキーマを元にデータベースを構成します。 npx prisma migrate dev 以上でデータベースと認証機能の設定は終了です。 フロントエンド対応 pages/index.tsx の中身を全部消して、以下のように記述します。 import { signIn , signOut , useSession } from 'next-auth/client' const IndexPage = () => { const [ session , loading ] = useSession () if( loading ) return null return <> { !session && <> Not signed in < br/ > < button onClick = { () => signIn () } > Sign in< /button > < / > } { session && <> Signed in as { session.user.email } < br/ > < button onClick = { () => signOut () } > Sign out < /button > < / > } < / > } export default IndexPage 次に pages/_app.ts を作成しNextAuthのProviderでラップします。 import { AppProps } from 'next/app' ; import { Provider } from 'next-auth/client' const App = ( { Component , pageProps } : AppProps ) => { return ( < Provider session = { pageProps.session } > < Component { ...pageProps } / > < /Provider > ) } export default App ここで npm run dev してみると以下のような画面が表示されると思います。 Sign inボタンを押し画面に表示されているフローに沿って進めると最終的にサインインしたアカウントのメールアドレスが表示されていると思います。 認証フローの中でSign in with Googleの画面が不用な方は、signIn()の引数にgoogleと渡すことでスキップできます。 import { signIn , signOut , useSession } from 'next-auth/client' const IndexPage = () => { const [ session , loading ] = useSession () if( loading ) return null return <> { !session && <> Not signed in < br/ > < button onClick = { () => signIn ( 'google' ) } > Sign in< /button > // googleを渡す < / > } { session && <> Signed in as { session.user.email } < br/ > < button onClick = { () => signOut () } > Sign out < /button > < / > } < / > } export default IndexPage これで認証機能の実装が完了です。 サインインしたあとのsessionの中身は以下のようになっています。 { user: { name: string , email: string , image: uri } , accessToken: string , expires: "YYYY-MM-DDTHH:mm:ss.SSSZ" } sessionに追加のデータ(ユーザーのIDなど)を渡したい場合は Session callback を使います。 [...nextauth].ts を以下のように変更します。 import NextAuth from "next-auth" import Providers from "next-auth/providers" import { PrismaAdapter } from "@next-auth/prisma-adapter" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient () export default NextAuth ( { // 1つ以上の認証プロバイダーを構成 providers: [ Providers.Google ( { clientId: process.env.GOOGLE_CLIENT_ID , clientSecret: process.env.GOOGLE_CLIENT_SECRET , } ), ] , adapter: PrismaAdapter ( prisma ), // ここから下を追加 callbacks: { session: async ( session , user ) => { return Promise.resolve ( { ...session , user: { ...session.user , id: user. id } } ); } } } ) pages/index.tsx のなかの session.user.email の部分を session.user.id に変えて取得できるか確認してみます。 コードを変更すると、今回はTypeScriptで開発しているので、userの中にidは存在しないと、型エラーが発生します。 そのため、 types/next-auth.d.ts を作成し、以下のように型を拡張します。 import NextAuth, { DefaultUser } from "next-auth" import { JWT } from "next-auth/jwt"; declare module "next-auth" { interface Session { user: User | JWT } interface User extends DefaultUser { id?: string | null } } こうすることで型エラーが消えて、idも取得できるかと思います。 特定のページで閲覧権限をチェックするため、以下のコンポーネントを作ります。 import { ReactNode } from 'react' ; import { signIn , useSession } from 'next-auth/client' ; type Props = { children?: ReactNode } const ProtectedPage = ( { children } : Props ) => { const [ session , loading ] = useSession () if( loading ) return null if( !loading && !session ) { return < button onClick = { () => signIn ( 'google' ) } > Sign in with Google < /button > } return ( < div > { children } < /div > ) } export default ProtectedPage サインインしている場合はchildrenを表示し、そうでない場合はサインイン用のボタンを表示します。 仮にIndexページはサインインしている状態でしか見れないとなった場合は以下のように修正します。 import ProtectedPage from '../components/ProtectedPage' const IndexPage = () => { return ( < ProtectedPage > < p > 🍭🍭🍭🍭🍭🍭 < /p > < /ProtectedPage > ) } export default IndexPage こうしてあげることで、サインインしてない場合は、以下の画面が表示され サインインしている場合は、以下の画面が表示されます。 そもそも全ページがサインインしていないと閲覧できない様なアプリケーションの場合は、 _app.ts の中でラップしてあげるといいですね。 本番環境にデプロイする際は env.local にNEXTAUTH_URLを記述します。 NEXTAUTH_URL = https://sample-app.com 加えて、Googleの承認済みのリダイレクトURIを本番用に追加するのを忘れないようにしましょう。 さいごに Next.jsにNextAuth.jsを使うことで簡単に認証機能の構築ができます。 加えて、Prismaをつかうことで柔軟な型推論や補完がきくので、とても良い開発体験も提供してくれます。 アプリケーションの実装をざっくりと説明したので、詳細まで理解しきれない方もいたと思いますが、まずは全体像が掴めたらいいなと思い記事にしました。 これをきっかけにNext.jsなどを使ったアプリケーションがたくさん開発されると嬉しいです。 スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。 スタメン エンジニア採用サイト インフラエンジニア募集ページ サーバーサイドエンジニア募集ページ フロントエンドエンジニア募集ページ モバイルアプリエンジニア募集ページ デザイナー募集ページ
モバイルアプリグループでおもにAndroidアプリ開発している @sokume です。 Android エンジニアの皆さん、Jetpack Composeへの準備はバッチリでしょうか。 2019年にJetpack Composeの発表があり、2021年7月にはJetpack Composeバージョン1.0がリリースされると Google I/O 2021 Developers Keynote の中で発表がありました。 この記事では、リリース目前となったJetpack Composeバージョン1.0に向けて、Jetpack Composeに関する情報をまとめて行きます。 開発環境 Jetpack Composeβ版は、 Android Studio Preview release のBeta build(2021/6/30 時点でArctic Fox(2020.3.1) Beta4で利用が可能です。 Android Studio Stable版 では利用できない点に注意してください。 7月のJetpack Compose1.0リリース時に、Android Studio Stable版でも利用可能なバージョンがでるような噂は聞きますが正式な発表は無いので、しばらくは Android Studio Preview release を利用する対応になるかもしれません。 最新の開発環境に関する情報は以下をチェック。 Android Studio でJetpack Composeを使用する Jetpack Composeを知る まずは Composeの思想 をチェックしましょう。 どの記載内容も大事ですが、まず理解しておきたい点は以下と思っています。 @ComposableアノテーションのついたComposable関数の役割 これまでAndroidで開発されてきた方は、作成してきたカスタムViewをイメージしながら考えると実装イメージが付きやすいかもしれないです。 Composable関数の説明として 副作用はありません や 副作用なしでUIを記述します という説明が出てきます。ここの 副作用 は 入力情報に対して結果が一致しない不確定な要素 と考えるとわかりやすいでしょうか。 Composable関数は入力値に対して、表示される結果は常に同じ結果になるUIである必要がある という考え方が必要になりそうです。 Composable関数の再コンポーズ 再コンポーズはComposable関数が、入力データを判断し 必要があれば 再描画される事を指します。 再コンポーズはアニメーション実行中など、頻繁に呼び出される可能性があるので、パフォーマンスの大きい動作(他のViewに影響のある値の変更)などは、Composable関数で実行しない工夫が最適なパフォーマンスの維持に繋がりそうですね。 これまでのListViewのようなレイアウトを組む場合には、 適切に再コンポーズをさせるための実装 が必要になります。 Jetpack Composeを学ぶ 順序良く学ぶには Jetpack Compose Pathway がベストでしょう。 上で記載している内容も出てきます。レイアウトの方法、アニメーションや画面遷移といった基本的な情報を順序良く学ぶことができます。 すべて完了すると、Google Developers Badge ももらえるので、集めている方は是非取り組んでください。 Jetpack Compose を読む 既にJetpack Compose で書かれたコードを読むというのも実装を知るための近道でしょう。 以下のGitHubに公式のJetpack Compose のサンプルアプリがあります。自分の作りたいアプリのデザインをイメージして探すと、実装のイメージが湧きそうですね。 github.com Jetpack Compose を書く Jetpack Compose を使ったアプリを作りたいが、どんなアプリをつくってみようと思っている場合には、2021年の3月〜4月に行われた Android Dev Challenge に挑戦してみるのはどうでしょう。 注意:既に登録期間は過ぎているので景品はもらえないですよ。 私もChallenge1〜4のすべてに挑戦しましたが、 Challange3 のAPAC(アジア太平洋)向けのお題 Bloom はとても良い内容でしたので、まだの方はチャレンジしてみてください。 全世界でDevChallangeに登録したアプリはすべて GitHubで探す ことが可能です。他の人が作ったアプリを参考にしてみるのも良いでしょう。 〆 Jetpack Composeバージョン1.0は非常に楽しみかつパワフルな機能で、今後のAndroid開発はJetpack Composeで実装するケースが増えていくでしょう。 先日私が司会を務めさせて頂いた、 I/O Extended Japan ーAndroid のイベント中で、視聴頂いている方からの質問を受け付けましたが、1時間の回答時間で回答しきれないほどの質問が来ました。質問内容もJetpack Composeに関する内容がたくさん寄せられて、Jetpack Composeへの期待を感じました。 www.youtube.com さぁ!Jetpack Composeバージョン1.0にすぐキャッチアップしていけるように、準備していきましょう! 株式会社スタメンでは一緒に働くエンジニアを募集しています。 ご興味のある方はぜひ エンジニア採用サイト をご覧ください。
TL;DR (概要) スタメン モバイルアプリGでAndroid・iOSアプリを開発してるカーキです! 昨年から、プロダクト部の勉強会の主催を担当させてもらっています。 突然ですが、皆さんの会社ではどのように社内勉強会を実施されているでしょうか? スタメンではこの6月からエンジニアの社内勉強会の方法を一新しました。 今回このブログでは、スタメンの社内勉強会を新たにどのように実施しているのかを紹介します。 今までの勉強会 新しい社内勉強会の紹介の前に、今までどのように社内勉強会を行っていたのかを紹介します。 3年ほど前から始まったエンジニアの社内勉強会では、全てのエンジニアが月一で集まり、LT形式で数人が各々が使用している技術のベストプラクティスを紹介したり、自己研鑽で学んでいる内容などを発表していました。 この形式の勉強会が始まった当時はエンジニアの数も10名弱で、エンジニアの構成としても全員がWebアプリケーションを担当するエンジニアでした。 そのため、LTの内容もWeb領域に寄っていましたが、特に問題はありませんでした。 課題 エンジニアの人数が増えてきたことで以下の2つの課題が発生してきました。 技術領域の異なるメンバーの増加によるLT内容の希薄化 人数が多すぎて勉強会の運営が難しくなってきた LT内容の希薄化 スタメンでは2年ほど前からモバイルアプリの開発に力を入れており、モバイルアプリエンジニアなど今までの技術領域と異なるエンジニアが社内でも増え始めてきていました。 全員で集まって技術的なLTをしても、Webのエンジニアにとってはモバイル領域のことを、モバイルのエンジニアにとってはWebのことを十分に理解することが難しくなってきました。 そのためお互いにLTの内容を理解できるレベルにまで合わせると、LTの内容が希薄化しているケースがありました。 またLTのレベルを下げない場合でも、他の領域だからとLTの理解を割り切ってしまうと、社内勉強会としての価値がそれぞれのメンバーにとって低下する恐れもありました。 人数の増加 スタメンでは創業事業である TUNAG や新規事業である FANTS の事業拡大に伴い、ここ1年だけで10名弱エンジニアが増加しました。 それに伴いオフィスで毎月、場所を借りて勉強会を運営するコストが増えてきました。 これにはスタメン本社全体の人数の増加による開催できる場所の不足も一因としてあります。 上記のような課題を感じ、エンジニアの社内勉強会を刷新する判断をしました。 新生・勉強会 社内勉強会を刷新するに当たって、社内勉強会の趣旨を新たに定めました。 勉強会の趣旨 学びたいことが学べる グループや役職を超えた交流 勉強会の趣旨として 「学びたいことが学べる」 ということは、当たり前のことではありますが、エンジニアのキャリアやバックグラウンドが多様化する中でエンジニアそれぞれが価値を感じることを学ぶことができる環境を提供することが大切だと感じています。 2つ目の 「グループや役職を超えた交流」 に関しては、FANTS事業のエンジニアの所属が従来のプロダクト部から分離したことが大きく影響しています。勉強会を刷新するきっかけにはなっていないものの、エンジニア同士の物理的な距離感だけでなく、所属組織としても距離が生まれようとしていました。 この問題に対して、勉強会としてもチャレンジできるのではないかと思い、趣旨として「グループや役職を超えた交流」という視点は重要だと考えました。 上記のような趣旨から以下の2種類の勉強会を開始することになりました。 新勉強会①グループ勉強会 一つ目の勉強会の形式が グループ勉強会 です これは特定の技術領域に特化した勉強を任意参加で実施する勉強会です。 勉強の主催自体も発起人自身が企画し、運営していきます。 このような形式の勉強会はすでにスタメン社内でも実施されていましたが、所属する組織単位でクローズで行っていたものがほとんどでした。 このクローズに開催されていた特定領域の勉強会をエンジニア全体でオープンにすることで、所属する組織を横断して学びの機会を増やすことを狙いとしました。 前述したFANTS事業部の分離に関しても、TUNAGとFANTSの技術領域が近いことから、2つの事業部のエンジニアが互いに交差して学び合えることをこの勉強会から期待しています。 この形式の勉強会は6月から始めましたが、すでにNext.jsの勉強会など計4つほどの勉強会グループが生まれています。 新勉強会②LT大会 2つ目の勉強会の形式が LT大会 です。 これは1ピリオド(スタメンでは4ヶ月毎のピリオド制を採用しています)に1回、社内のエンジニア、デザイナー、PdMを集めてゆるめのLT大会を実施します。 こちらはどちらかというと 交流 に重点を置いた勉強会になります。 2つの事業部のエンジニア同士の交流はもちろんですが、スタメン社内のデザイナーやPdMなども含めたプロダクトに関わるメンバー全体の交流を目的としています。 またLT大会では会期ごとに運営メンバーを募る形で実施しています。 運営メンバーが固定化されてしまうと、参加側のメンバーの意識としてどうしても受動的にならざるえなくなります。 LT大会のような規模の大きいイベントでは、運営側に立つことで初めてわかることも多く、勉強会の運営経験をすることにも十分な価値があると感じています。 現在、7月の上旬にLT大会を予定しており、運営メンバーが日々調整に励んでいます。 まとめ 僕自身、社内勉強会の方式を変えるタイミングで様々なテックブログを拝見し、各社がどのように勉強会を行なっているのかを参考にしていました。 今回のこのブログが社内勉強会について考えるきっかけになれば幸いです。 スタメンでの社内勉強会の取り組みについて紹介してきましたが、 スタメンでは勉強会が大好きな、向上心のあるエンジニアを募集しています。 詳細は以下のリンクからご覧ください。
はじめに こんにちは、スタメンでエンジニアをしている手嶋です。普段は、React+TypeScriptでフロントエンドメインで開発をしています。 弊社のプロジェクトではフロントエンドの状態管理ライブラリとして Redux を使用していますが、直近のプロジェクトにおいて Redux の Store に格納するデータを正規化することで多くの恩恵を得られた為、今回はそのメリット及び具体的な正規化の方法について紹介したいと思います。 ※ Redux の 公式ドキュメント でも Store の正規化が推奨されています。 正規化の概要及びメリットについて 正規化の概要 正規化されたデータがどのようなデータなのか示すために以下に例を挙げます。 今回はユーザーによるプロフィール入力があるアプリケーションにおけるAPIを例に挙げます。(ユーザーが各質問に対して回答を行うようなデータです) 実際のプロジェクトにおける正規化の対象としては、APIから取得したデータ全般を想定していただければと思います。 正規化前のデータ(APIレスポンス) const profileData = [ { id: 'questionId1' , name: '勤務先' , answers: [ { id: 'answerId1' , content: '東京都渋谷区' , private : false , //公開非公開の設定 } , ] , } , { id: 'questionId2' , name: '性別' , answers: [ { id: 'answerId2' , content: '男性' , private : false , } , ] , } , { id: 'questionId3' , name: '学歴' , answers: [ { id: 'answerId3' , content: 'hoge高校' , private : true , } , { id: 'answerId4' , content: 'fuga大学' , private : true , } , // 同じような学歴のデータが続く ] , } , // 同じような質問と回答のデータが続く ] 上記のままでも Store に格納することはできますが、以下のデメリットが発生します。 各エンティティ(上記でいうquestionsとanswers)のデータが混在して Store に保存されているため、どこかを更新する際に、その対象が適切に更新されているか不明瞭である データがネスト化されていることで、データの更新ロジックが複雑になったり、処理に想定以上の時間を要する可能性がある(特にデータが多い場合) イミュータブルなデータの更新はそのデータの全ての親要素の更新も伴う為、不必要なデータの更新が起こることになり、結果的にコンポーネントで不要な再描画が発生する可能性が高い(上記の例でいうと、ある単一のanswerに対する更新であってもprofileData全体への更新処理となる為、profileDataを参照している全てのコンポーネントで再描画が発生してしまいます) 以上を踏まえて正規化したデータが以下になります。 正規化後のデータ // questionのテーブル const questions = { ids: [ 'questionId1' , 'questionId2' , 'questionId3' ] , questionId1: { id: 'questionId1' , name: '勤務先' , answerIds: [ 'answerId1' ] } , questionId2: { id: 'questionId2' , name: '性別' , answerIds: [ 'answerId2' ] } , questionId3: { id: 'questionId3' , name: '学歴' , answerIds: [ 'answerId3' , 'answerId4' ] } , } // answerのテーブル const answers = { answerId1: { id: 'answerId1' , content: '東京都渋谷区' , private : false } , answerId2: { id: 'answerId2' , content: '男性' , private : false } , answerId3: { id: 'answerId3' , content: 'hoge高校' , private : true } , answerId4: { id: 'answerId4' , content: 'fuga高校' , private : true } , } Redux/Store が正規化されている状態を定義すると以下となります。 データの重複がない データ(エンティティ)ごとに「テーブル」をstateとして持っている 正規化されたデータはIDをkeyとして、データ自体がそのvalueとなる IDを持つ配列は順序を示す 正規化のメリット メリットとしては以下が挙げられます。 Store の整合性が常に取れている ネストが浅いので複雑性がない。また、値の更新に伴う不必要な値の巻き込み更新が最小限になる 値の更新時にjsの配列操作であるfilterなどを用いなくてもダイレクトに参照可能である(例えばanswerId3を更新したい場合 state.answers[answerId3] のようにシンプルに参照することができます。これはデータ探索の観点でパフォーマンスの向上が期待できます。) 前述のように本来操作が必要の無いデータに対する操作(更新/削除などの処理)が無くなる為、コンポーネント再描画などの観点から パフォーマンス面 においても恩恵を得られる 正規化の方法について 次に正規化の具体的な手法を紹介します。 弊社のプロジェクトでは基本的に normalizr というライブラリを使っています。導入方法や詳細は公式の github が参考になると思います。 APIからデータ取得後、Storeにデータを格納する直前に正規化の処理を挟んでいます。以下は上述の profileData を正規化する場合の例となります。 normalizrを使った正規化の例 処理としては大きく2段階になっており、エンティティ毎の schema を定義する処理と、実際にAPIのレスポンスを normalize して呼び出し元に値を返す処理です。 あくまで一例ですので、同じよう schema 定義と normalize をする事であらゆるデータを正規化する事ができると思います。 import { normalize , NormalizedSchema , schema } from 'normalizr' import { ProfileDataType } from 'types/profile' // エンティティ毎のschemaを定義する関数 export const createProfileDataSchema = () => { const profileData = new schema. Entity ( 'questions' , { profileAnswers: [ new schema. Entity ( 'answers' ) ] , } ) return [ profileData ] } // Reducerから呼び出す関数。APIから取得したデータを引数として受け取り、正規化後の結果を返す。 export const profileDataNormalizer = ( profileData: ProfileDataType [] ) => { const profileDataSchema = createProfileDataSchema () return normalize ( profileData , profileDataSchema ) } また、弊社のプロジェクトでAPIのデータが木構造になっている複雑なケースもありましたが、上記の処理を少し変更するだけ同じように正規化する事ができました。以下に続けて紹介します。 APIレスポンスが木構造の場合 APIのレスポンスでquestionが木構造になっており、ある質問の子要素として別の質問があるパターンです。 const profileData = [ { id: 'questionId1' , name: '勤務先' , answers: [ { id: 'answerId1' , content: '東京都渋谷区' , private : false , } , ] , } , { id: 'questionId2' , name: '基本情報' , // ここに木構造のデータとしてchildrenのデータが含まれる children: [ { id: 'questionId5' , parentQuestionId: 'questionId2' , name: '性別' , answers: [ { id: 'answerId5' , content: '男性' , private : false , } , ] , } , { id: 'questionId6' , parentQuestionId: 'questionId2' , name: '誕生日' , answers: [ { id: 'answerId6' , content: '1月1日' , private : false , } , ] , } , ] , } , { id: 'questionId3' , name: '学歴' , answers: [ { id: 'answerId3' , content: 'hoge高校' , private : true , } , { id: 'answerId4' , content: 'fuga大学' , private : true , } , // 同じような学歴のデータが続く ] , } , // 同じような質問と回答のデータが続く ] 正規化の処理 この場合は、正規化の処理の中でchildrenを定義する処理を追加することで、同じように正規化することができます。 // normalizrを使った正規化の処理 import { normalize , NormalizedSchema , schema } from 'normalizr' import { ProfileDataType } from 'types/profile' // エンティティ毎のschemaを定義する関数 export const createProfileDataSchema = () => { const profileData = new schema. Entity ( 'questions' , { profileAnswers: [ new schema. Entity ( 'answers' ) ] , } ) // 木構造のデータを扱う場合に追加した処理 profileData.define ( { children: [ profileData ] } ) return [ profileData ] } // Reducerから呼び出す関数。APIから取得したデータを引数として受け取り、正規化後の結果を返す。 export const profileDataNormalizer = ( profileData: ProfileDataType [] ) => { const profileDataSchema = createProfileDataSchema () return normalize ( profileData , profileDataSchema ) } APIの仕様次第で独自で実装した方が費用対効果が大きいケースもあると思いますが、ある程度複雑なAPIであっても normalizr でスムーズに正規化することが可能ですので、正規化の一つの選択肢として十分候補になると考えています。 おわりに Redux/Store に格納するデータを正規化するメリットや手法について紹介しました。 正規化するコスト以上に得られるメリット( Store の複雑性の排除・コンポーネント再描画対策)が大きいと感じたので、今後のプロジェクトにも導入していきたいと思っています。 最後まで読んで頂きありがとうございました。 スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。 スタメン エンジニア採用サイト インフラエンジニア募集ページ サーバーサイドエンジニア募集ページ フロントエンドエンジニア募集ページ モバイルアプリエンジニア募集ページ デザイナー募集ページ 参考にさせていただいた資料 https://tech.aptpod.co.jp/entry/2020/06/26/090000 https://zenn.dev/irico/articles/e5ae4b7d23fb69
目次 はじめに Storybookとは メリット 導入方法 サンプルの解説 アドオンの紹介 おわりに はじめに 初めまして。 株式会社スタメンのフロントエンドグループでエンジニアをしている神尾です。 普段は弊社が運営しているエンゲージメントプラットフォーム TUNAG の開発をしています。 今回の記事では、フロントエンドの開発で使用される Storybook のメリットや導入方法、使い方についてお話したいと思います。 Storybookとは UIコンポーネントの管理・テストをすることが出来るオープンソースツール。 サンドボックス環境を構築し、その環境下でコンポーネントの挙動や表示を確認できる他、カタログのようにコンポーネントを一目で見ることができる。 React、Vue、Angularなどの主要なJSフレームワークで導入でき、利用範囲も広い。 などの特徴があります。 メリット UIコンポーネントのカタログとして視覚的に確認できることでエンジニアとデザイナーの認識の齟齬を無くすことが出来る。 カタログから探すことが出来るので、再利用したい時にすぐに調べることが出来る。 コードの変更が即時に画面に反映されるため、開発作業が素早く行える。 UIの変更に対して、ビジュアルリグレッションテストをすることができる。 Reactでの導入方法 # Storybookのサンプルアプリを作成 $ npx create-react-app react-storybook-sample # 作成したサンプルアプリに移動 $ cd react-storybook-sample # Storybookをインストール $ npx -p @storybook/cli sb init 上記のコマンドを実行するとpackage.jsonにStorybookに必要なパッケージが追加・インストールされ、フォルダが作成されます。 下記は、作成されるフォルダの一部を抜粋しています。 - .stories/  - main.js  - preview.js   - src/  - assets/  - button.css  - Button.js  - Button.stories.js  - その他サンプルのコンポーネント ここまで正しくインストールできたら下記のコマンドを実行して、画像のように表示されたら準備完了です。 (自動的に表示されない場合は http://localhost:6006/ にアクセスしてみてください。) # StoryBookを起動する。 $ yarn storybook 画面左側には、Storybookに登録されているコンポーネントのリストが表示されており、右側では、コンポーネントが視覚的に確認することができます。 サンプルの解説 Button.js import React from 'react' ; import PropTypes from 'prop-types' ; import './button.css' ; export const Button = ( { primary, backgroundColor, size, label, ...props } ) => { const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary' ; return ( <button type= "button" className= {[ 'storybook-button' , `storybook-button--$ { size } `, mode ] .join( ' ' ) } style= { backgroundColor && { backgroundColor }} { ...props } > { label } </button> ); } ; Button.propTypes = { primary: PropTypes.bool, backgroundColor: PropTypes.string, size: PropTypes.oneOf( [ 'small' , 'medium' , 'large' ] ), label: PropTypes.string.isRequired, onClick: PropTypes.func, } ; Button.defaultProps = { backgroundColor: null , primary: false , size: 'medium' , onClick: undefined , } ; Button.stories.js import React from 'react' ; import { Button } from './Button' ; export default { title: 'Example/Button' , component: Button, } ; const Template = (args) => <Button { ...args } />; export const Primary = Template.bind( {} ); Primary.args = { primary: true , label: 'Button' , } ; export const Secondary = Template.bind( {} ); Secondary.args = { label: 'Button' , } ; インストール時にサンプルとして、いくつかのコンポーネントとStoryが作成されています。 上記は、その中のひとつであるButtonコンポーネント(Button.js)とButtonコンポーネントをStoryBookに上げるためのファイル(Button.stories.js)です。 ここからButton.stories.jsでどのようにStorybookに表示させているかを見ていきます。 import { Button } from './Button' ; // Storybookに表示させたいButtonコンポーネントをimport export default { title: 'Example/Button' ,    // Storybookのディレクトリ・ファイル名 component: Button,     // 対象のコンポーネントを指定 } ; export defaultでは、Storybookのタイトルや対象のコンポーネントを定義しています。 ここで定義したファイル構成が実際にStorybook上での構成となります。 export default { args: { primary: true , } , } ; また、上記のように書くことで全てのStoryに共通の引数を渡すことも出来ます。 今回の例では、これから定義する全てのButtonコンポーネントに対して primary: true が渡されることになります。 その他にもexport defaultでは、いくつかの設定が出来るので、詳しくはこちらの 公式サイト をご覧ください。 const Template = (args) => <Button { ...args } />;  // argsで引数を受け取って、Buttonに渡すTemplateを定義。 export const Primary = Template.bind( {} ); // 各StoryでTemplateをBindして再利用 Primary.args = { // 引数を定義 primary: true , label: 'Button' , } ; export const Secondary = Template.bind( {} ); Secondary.args = { label: 'Button' , } ; 続いて、ここでは実際にStorybookに表示するPrimaryボタンとSecondaryボタンが定義されています。 初めに、argsで受け取った引数をButtonのコンポーネントに渡すTemplateを定義して、引数によって表示が異なるButtonで使いまわせるようにしています。 その後、Templateをbindしてargsに引数を定義し、Buttonコンポーネントに必要な引数が渡されPrimaryButtonが表示されます。 このようにすることで、下記の画像のようにButtonが表示されます。 main.js module.exports = { stories: [ '../src/**/*.stories.js' ] , // ロードするファイルを指定。 "addons" : [ "@storybook/addon-links" ,     // StoryBookを拡張するアドオンを指定。 "@storybook/addon-essentials" , "@storybook/preset-create-react-app" ] } main.jsでは、Storybookにロードするファイルを指定している他、いくつかアドオンがデフォルトで追加されています。 アドオンをインストール後は、main.jsに使用するアドオンを追加します。 アドオンの紹介 Viewport Storybook上で、レスポンシブなデザインを確認できるアドオン。 デバイスを設定することで簡単に幅を変更できる。 コードの変更がすぐに反映されるため、カタログとしてだけでなく開発中においても、デバイス毎のデザインを確認する時にも便利。 Controll Storybook上で、コンポーネントに渡す引数を変更できるアドオン コードを書き換えることなく画面上でlabelやbackgroundcolorなどを変更できる。 おわりに 最後まで読んでいただきありがとうございます! 今回の記事では、Storybookとは?導入するメリットは?ということから、実際にどのように登録されているのかということをお話させていただきました。Storybookって聞いたことはあるけど、何か分からないという方が「便利そうだから試しにやってみようかな!」と思ってもらえたら嬉しいです。 スタメンでは一緒に働くエンジニアを募集しています。興味がある方は、ぜひ 採用サイト からご連絡ください。お待ちしております!
はじめに こんにちは、スタメンの松谷( @uuushiro )です。この記事では、MySQLのパフォーマンススキーマを利用し、トランザクションの実行時間を調査する方法を紹介します。なお、検証に利用した実行環境は Amazon Aurora MySQL5.7互換 です。 なぜトランザクションの実行時間を調査したいのか 過去に弊社が提供するWEBサービスのデータベースに、ALTER文などのデータ定義言語(以下DDL)をオンラインで実行した際、DDL対象のテーブルへのクエリが「Waiting for table metadata lock」という待機状態になり、結果として障害に繋がったことがありました。なぜトランザクションの実行時間を調査したいのかを説明する前に、まずこの「Waiting for table metadata lock」について少し説明します。 テーブルに対する オンライン DDL は、DDL実行前後に対象テーブルへの排他的アクセス(metadata lock)が必要なため、そのテーブルにアクセスしている実行中のトランザクションがコミットまたはロールバックするのを待機するようになっています。このとき、待機状態になっているDDL の実行ステータスは 「Waiting for table metadata lock」となっており、実行中のトランザクションが完了するまで待ちが発生します。 さらに、DDLが 「Waiting for table metadata lock」状態のときに対象テーブルへSELECT文などのデータ操作言語(以下DML)を実行した場合、このDMLの実行ステータスも「Waiting for table metadata lock」となり待機状態になります。 つまり、DDLが実行される前に実行中だったトランザクションの実行時間分だけ、後続の対象テーブルに対する DDL, DML が待機することになります。 このようにトランザクションの実行時間がクエリの待機状態の継続時間に直接影響する場合があります。しかし、本番環境のデータベースにおいて実際にトランザクションがどれほど長く、頻繁に実行されているのか?を私が把握していなかったため今回調査をしてみました。 トランザクション実行時間の調査方法 performance_schema を利用すればトランザクションの実行時間を追跡できます。 performance_schema に関しては、 performance_schemaをsysで使い倒す! の記事がとても詳しいので是非参照ください。ここでは要点を絞って順に説明します。 パフォーマンススキーマが有効かどうか確認 まず、パフォーマンススキーマが有効かどうかを確認します。performance_schema変数の値がONであれば有効です。 (MySQL5.7以降はデフォルトONになっています) mysql> SHOW VARIABLES LIKE ' performance_schema ' ; + --------------------+-------+ | Variable_name | Value | + --------------------+-------+ | performance_schema | ON | + --------------------+-------+ setup_instruments と setup_consumers について トランザクションイベントを収集できるように、パフォーマンススキーマの設定関連のテーブル setup_instruments と setup_consumers を更新します。 setup_instrumentsは、MySQLのソースコード内に設置された、処理時間や待機時間を収集するための計器(instruments)の設定テーブルです。以下のように、テーブルの中身は各instrumentsのどれが有効にされているかを示しています。(トランザクションに関係のあるinstrumentsに絞って表示しています) mysql> select * from performance_schema.setup_instruments where name like ' %transaction% ' ; + -------------------------------------------------------+---------+-------+ | NAME | ENABLED | TIMED | + -------------------------------------------------------+---------+-------+ | wait/synch/mutex/sql/LOCK_transaction_cache | NO | NO | | stage/sql/Waiting for preceding transaction to commit | NO | NO | | stage/sql/Waiting for dependent transaction to commit | NO | NO | | transaction | NO | NO | | memory/sql/THD::transactions::mem_root | NO | NO | + -------------------------------------------------------+---------+-------+ setup_consumersは、上記のinstrumentで収集した、現在(current)・過去の(history)データを保存するかどうかの設定テーブルです。以下のように、テーブルの中身は各consumerのどれを有効にしているかを示しています。 mysql> SELECT * FROM performance_schema.setup_consumers; + ----------------------------------+---------+ | NAME | ENABLED | + ----------------------------------+---------+ | events_stages_current | NO | | events_stages_history | NO | | events_stages_history_long | NO | | events_statements_current | YES | | events_statements_history | YES | | events_statements_history_long | NO | | events_transactions_current | NO | | events_transactions_history | NO | | events_transactions_history_long | NO | | events_waits_current | NO | | events_waits_history | NO | | events_waits_history_long | NO | | global_instrumentation | YES | | thread_instrumentation | YES | | statements_digest | YES | + ----------------------------------+---------+ 設定変更 今回は、トランザクションに関する instruments と consumers を有効化します。 以下のように、NAMEが 'transaction' のinstrumentを ENABLED = YES とします。(TIMEDはinstrumentの時間が測定されるかどうかのフラグで今回YESとしています。) UPDATE performance_schema.setup_instruments SET ENABLED = ' YES ' , TIMED = ' YES ' WHERE NAME = ' transaction ' ; 続いて、NAMEが「events_transactions_history_long」のconsumerを有効にします。(events_transactions_history_longは、すべてのスレッドでグローバルに終了した最新のトランザクションイベントが含まれます) mysql> UPDATE performance_schema.setup_consumers SET ENABLED = ' YES ' WHERE NAME = ' events_transactions_history_long ' ; 上記で有効化した events_transactions_history_long で取得できるフィールドは以下になります。トランザクションの実行時間(TIMER_WAIT)は取得できますが、実行されたSQL情報は取得できません。 mysql> describe events_transactions_history_long; + ---------------------------------+------------------------------------------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | + ---------------------------------+------------------------------------------------+------+-----+---------+-------+ | THREAD_ID | bigint( 20 ) unsigned | NO | | NULL | | | EVENT_ID | bigint( 20 ) unsigned | NO | | NULL | | | END_EVENT_ID | bigint( 20 ) unsigned | YES | | NULL | | | EVENT_NAME | varchar ( 128 ) | NO | | NULL | | | STATE | enum( ' ACTIVE ' , ' COMMITTED ' , ' ROLLED BACK ' ) | YES | | NULL | | | TRX_ID | bigint( 20 ) unsigned | YES | | NULL | | | GTID | varchar ( 64 ) | YES | | NULL | | | XID_FORMAT_ID | int( 11 ) | YES | | NULL | | | XID_GTRID | varchar ( 130 ) | YES | | NULL | | | XID_BQUAL | varchar ( 130 ) | YES | | NULL | | | XA_STATE | varchar ( 64 ) | YES | | NULL | | | SOURCE | varchar ( 64 ) | YES | | NULL | | | TIMER_START | bigint( 20 ) unsigned | YES | | NULL | | | TIMER_END | bigint( 20 ) unsigned | YES | | NULL | | | TIMER_WAIT | bigint( 20 ) unsigned | YES | | NULL | | | ACCESS_MODE | enum( ' READ ONLY ' , ' READ WRITE ' ) | YES | | NULL | | | ISOLATION_LEVEL | varchar ( 64 ) | YES | | NULL | | | AUTOCOMMIT | enum( ' YES ' , ' NO ' ) | NO | | NULL | | | NUMBER_OF_SAVEPOINTS | bigint( 20 ) unsigned | YES | | NULL | | | NUMBER_OF_ROLLBACK_TO_SAVEPOINT | bigint( 20 ) unsigned | YES | | NULL | | | NUMBER_OF_RELEASE_SAVEPOINT | bigint( 20 ) unsigned | YES | | NULL | | | OBJECT_INSTANCE_BEGIN | bigint( 20 ) unsigned | YES | | NULL | | | NESTING_EVENT_ID | bigint( 20 ) unsigned | YES | | NULL | | | NESTING_EVENT_TYPE | enum( ' TRANSACTION ' , ' STATEMENT ' , ' STAGE ' , ' WAIT ' ) | YES | | NULL | | + ---------------------------------+------------------------------------------------+------+-----+---------+-------+ SQL情報の確認 そこで、SQL情報を追加するために、過去に実行されたSQLの中身が確認できるようになるconsumer「events_statements_history_long」を有効化します。 UPDATE performance_schema.setup_consumers SET ENABLED = ' YES ' WHERE NAME = ' events_statements_history_long ' ; 上記で有効化した events_statements_history_long で取得できるフィールドは以下になります。実行されたSQL情報(SQL_TEXT)が取得できます。 mysql> describe events_statements_history_long; + -------------------------+------------------------------------------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | + -------------------------+------------------------------------------------+------+-----+---------+-------+ | THREAD_ID | bigint( 20 ) unsigned | NO | | NULL | | | EVENT_ID | bigint( 20 ) unsigned | NO | | NULL | | | END_EVENT_ID | bigint( 20 ) unsigned | YES | | NULL | | | EVENT_NAME | varchar ( 128 ) | NO | | NULL | | | SOURCE | varchar ( 64 ) | YES | | NULL | | | TIMER_START | bigint( 20 ) unsigned | YES | | NULL | | | TIMER_END | bigint( 20 ) unsigned | YES | | NULL | | | TIMER_WAIT | bigint( 20 ) unsigned | YES | | NULL | | | LOCK_TIME | bigint( 20 ) unsigned | NO | | NULL | | | SQL_TEXT | longtext | YES | | NULL | | | DIGEST | varchar ( 32 ) | YES | | NULL | | | DIGEST_TEXT | longtext | YES | | NULL | | | CURRENT_SCHEMA | varchar ( 64 ) | YES | | NULL | | | OBJECT_TYPE | varchar ( 64 ) | YES | | NULL | | | OBJECT_SCHEMA | varchar ( 64 ) | YES | | NULL | | | OBJECT_NAME | varchar ( 64 ) | YES | | NULL | | | OBJECT_INSTANCE_BEGIN | bigint( 20 ) unsigned | YES | | NULL | | | MYSQL_ERRNO | int( 11 ) | YES | | NULL | | | RETURNED_SQLSTATE | varchar ( 5 ) | YES | | NULL | | | MESSAGE_TEXT | varchar ( 128 ) | YES | | NULL | | | ERRORS | bigint( 20 ) unsigned | NO | | NULL | | | WARNINGS | bigint( 20 ) unsigned | NO | | NULL | | | ROWS_AFFECTED | bigint( 20 ) unsigned | NO | | NULL | | | ROWS_SENT | bigint( 20 ) unsigned | NO | | NULL | | | ROWS_EXAMINED | bigint( 20 ) unsigned | NO | | NULL | | | CREATED_TMP_DISK_TABLES | bigint( 20 ) unsigned | NO | | NULL | | | CREATED_TMP_TABLES | bigint( 20 ) unsigned | NO | | NULL | | | SELECT_FULL_JOIN | bigint( 20 ) unsigned | NO | | NULL | | | SELECT_FULL_RANGE_JOIN | bigint( 20 ) unsigned | NO | | NULL | | | SELECT_RANGE | bigint( 20 ) unsigned | NO | | NULL | | | SELECT_RANGE_CHECK | bigint( 20 ) unsigned | NO | | NULL | | | SELECT_SCAN | bigint( 20 ) unsigned | NO | | NULL | | | SORT_MERGE_PASSES | bigint( 20 ) unsigned | NO | | NULL | | | SORT_RANGE | bigint( 20 ) unsigned | NO | | NULL | | | SORT_ROWS | bigint( 20 ) unsigned | NO | | NULL | | | SORT_SCAN | bigint( 20 ) unsigned | NO | | NULL | | | NO_INDEX_USED | bigint( 20 ) unsigned | NO | | NULL | | | NO_GOOD_INDEX_USED | bigint( 20 ) unsigned | NO | | NULL | | | NESTING_EVENT_ID | bigint( 20 ) unsigned | YES | | NULL | | | NESTING_EVENT_TYPE | enum( ' TRANSACTION ' , ' STATEMENT ' , ' STAGE ' , ' WAIT ' ) | YES | | NULL | | | NESTING_EVENT_LEVEL | int( 11 ) | YES | | NULL | | + -------------------------+------------------------------------------------+------+-----+---------+-------+ パフォーマンススキーマでは、ステートメントイベント(events_statements_history_long)はトランザクションイベント(events_transactions_history_long)内にネストされます(1トランザクションイベントの中で、複数のステートメントイベント(SQL)が実行されるので)。このため、events_transactions_history_long.event_id が events_statements_history_long.nesting_event_id と対応づけされています。 つまり、events_transactions_history_long.event_id = events_statements_history_long.nesting_event_id の条件でJOINをすれば、トランザクション時間とSQLを一緒に表示することができます。 以下で、SQLの例を示します。 SELECT transactions.thread_id, transactions.event_id, transactions.nesting_event_id, sys.format_time(transactions.timer_wait), statements.nesting_event_id, statements.sql_text FROM performance_schema.events_transactions_history_long AS transactions JOIN performance_schema.events_statements_history_long AS statements ON transactions.event_id = statements.nesting_event_id; 以下が結果例です。トランザクションのスレッドID、実行時間、SQL情報を確認することができました。 + -----------+----------+------------------+------------------------------------------+------------------+-------------------------------------------------------------------------------------------------------------------------- | thread_id | event_id | nesting_event_id | sys.format_time(transactions.timer_wait) | nesting_event_id | sql_text | + -----------+----------+------------------+------------------------------------------+------------------+-------------------------------------------------------------------------------------------------------------------------- | 3926618 | 4665986 | 4665979 | 174 . 72 ms | 4665986 | SELECT 1 AS one FROM `hoge` WHERE `hoge`.`name` = ' huga ' LIMIT 1 | | 3926618 | 4665986 | 4665979 | 174 . 72 ms | 4665986 | INSERT INTO `hoge` (`user_id`, `created_at`, `updated_at`) VALUES ( 99999 , ' 2021-05-13 10:18:23 ' , ' 2021-05-13 10:18:23 ' ) | | 3926618 | 4665986 | 4665979 | 174 . 72 ms | 4665986 | UPDATE `hoge` SET `updated_at` = ' 2021-05-13 10:18:23.912636 ' WHERE `hoge`.`user_id` = 999 | | 3926618 | 4665986 | 4665979 | 174 . 72 ms | 4665986 | UPDATE `hoge` SET `updated_at` = ' 2021-05-13 10:18:23.762849 ' WHERE `hoge`.`id` = 9999 AND `hoge`.`user_id` = 999 | | 3926618 | 4665986 | 4665979 | 174 . 72 ms | 4665986 | COMMIT | | 3926536 | 1651132 | 1651125 | 103 . 89 ms | 1651132 | SELECT `hoge`.* FROM `hoge` WHERE `hoge`.`id` = 514 LIMIT 1 | | 3926536 | 1651132 | 1651125 | 103 . 89 ms | 1651132 | COMMIT | | 3926485 | 1385528 | 1385521 | 134 . 75 ms | 1385528 | SELECT `hoge`.* FROM `hoge` WHERE `hoge`.`id` = 9999 LIMIT 1 | | 3926485 | 1385528 | 1385521 | 134 . 75 ms | 1385528 | SELECT `hoge`.* FROM `hoge` WHERE `hoge`.`id` = 9999 LIMIT 1 | | 3926485 | 1385528 | 1385521 | 134 . 75 ms | 1385528 | SELECT `hoge`.* FROM `hoge` WHERE `hoge`.`id` = 9999 LIMIT 1 | | 3926485 | 1385528 | 1385521 | 134 . 75 ms | 1385528 | COMMIT | + -----------+----------+------------------+------------------------------------------+------------------+-------------------------------------------------------------------------------------------------------------------------- 監視 本番環境において、どれくらい長いトランザクションがどれくらいの頻度発生しているのかを確認するために、定期的にポーリングし、Slackなどに通知する監視プログラムをRubyで作成しました。 events_transactions_history_long と events_statements_history_long が保存できる履歴数は、performance_schema_events_transactions_history_long_size および performance_schema_events_statements_history_long_size で決まっており、この数を超えると履歴から消えてしまうので、履歴テーブルから消えないくらいのタイミングでポーリングする頻度を調整しています(以下の例では3秒としています)。 また、先程のSQLのWHERE句に「transactions.timer_wait > 10000000000000」を加えれば、10秒以上実行されたトランザクションに絞ることができます。 # 10秒以上のトランザクションを取得する sql = <<- SQL SELECT transactions.thread_id, transactions.event_id, transactions.nesting_event_id, sys.format_time(transactions.timer_wait), statements.nesting_event_id, statements.sql_text FROM performance_schema.events_transactions_history_long AS transactions JOIN performance_schema.events_statements_history_long AS statements ON transactions.event_id = statements.nesting_event_id WHERE transactions.timer_wait > 10000000000000; SQL mysql = Mysql2 :: Client .new( host : host, username : username, password : password, database : database) begin loop do results = mysql.query(sql, as : :hash ) if results.size > 0 message = " Long transaction detected. performance_schema's rows: #{ results.to_a.to_s }" Bugsnag .notify(message) # 外部へ通知 end sleep 3 end rescue => e Bugsnag .notify(e) # 外部へ通知 ensure mysql.close Bugsnag .notify( " detect_long_transaction stopped. " ) # 外部へ通知 end まとめ パフォーマンススキーマを利用することで、トランザクションの実行時間を取得できるようになりました。 この情報を利用し、ほぼリアルタイムで本番環境で実行されているトランザクションを監視することで、以下のように改善が進みました。 どのテーブルでどれくらいの長さのトランザクションがどういった頻度で実行されているのか?が可視化され、オンラインDDLを実行したときのリスク評価ができるようになった。 どのアプリケーションコードが長時間のトランザクションを発生させているのかが分かるようになり、アプリケーションコードの改善が進んだ。 「Waiting for table metadata lock」という待機状態が長時間発生した場合にも、原因となるトランザクションのスレッドIDをすぐに取得できるので、問題の処理を素早くKILLできる(問題がなければ)。 今回の調査で少しパフォーマンススキーマに関しての理解が深まりました。今後もMySQLの理解を深めていければと思います! 最後になりますが、スタメンでは自社プロダクトの開発する仲間を募集しています。興味を持ってくれた方は、ぜひ下記の採用サイトをご覧ください。 スタメン エンジニア採用サイト インフラエンジニア募集ページ サーバーサイドエンジニア募集ページ フロントエンドエンジニア募集ページ モバイルアプリエンジニア募集ページ デザイナー募集ページ 参考にさせていただいた資料 MySQL :: MySQL 5.6 リファレンスマニュアル :: 8.10.4 メタデータのロック MySQL :: MySQL 5.6 リファレンスマニュアル :: 14.11.2 オンライン DDL でのパフォーマンスと並列性に関する考慮事項 Waiting for table metadata lockとオンラインDDLについて【MySQL5.6】 - 銀行員からのRailsエンジニア performance_schemaをsysで使い倒す! | Think IT(シンクイット) MySQLでトランザクションを追跡したい ~その1~ - 三流エンジニアの落書き帳
mohamed Hassan による Pixabay からの画像 こんにちは、スタメンでモバイルアプリ開発を担当している @temoki です。 2月に Mobile Act ONLINE #3 というオンライン勉強会に参加し、 iOSパッケージマネージャー奮闘記 というテーマで発表しました(詳しくは以下のスライドをご覧ください)。 この発表でお話しした内容の背景にあるのは CIでのiOSアプリビルド時間を短縮したい ということです。CIサービスは実行時間が利用料金に関連しますし、何よりユニットテストやアプリのテスト配信などの待ち時間は極力減らしたいですよね。 弊社のiOSアプリのビルド時間に大きく影響しているのは Firebase iOS SDK などの依存パッケージのビルドです。そこでこの記事では。依存パッケージの導入方法の工夫により、CIでのビルド時間を削減する過程をお話ししようと思います。 この記事の内容は以下の環境で実施した結果です。 開発環境 MacBook Pro 2018 / CPU : 2.2GHz 6コアIntel Core i7 / メモリ : 16GB Xcode 12.5 Carthage 0.37.0 依存パッケージ Firebase iOS SDK (Analytics, Auth, Firestore, Cloud Messaging など全7種類) 他、10種類のパッケージとそれらが依存するパッケージで計17種類 Swift Package Manager を利用する iOS 向けのパッケージマネージャーは主に以下の3つから選択することになります。 CocoaPods *1 Carthage *2 Swift Package Manager *3 (以降、SwiftPMと記載) SwiftPM は唯一の Swift公式パッケージマネージャーですが、他に比べると後発で、iOSアプリ開発で利用できるようになったのも2019年とまだまだ若いツールです。しかし、この2年でツールとしての課題や各パッケージの対応状況が大きく改善されました。現在では第一選択にしても良いレベルになってきていますので、まずは SwiftPM を選択してみます。 弊社が提供しているアプリでは、Firebase iOS SDK *4 に加えて十数種類のパッケージに依存しています。Firebase iOS SDK は2020年8月に SwiftPM 経由でのインストールがベータ版という扱いで提供されるようになりましたし *5 、他の依存パッケージもすべて SwiftPM への対応が完了していましたので、すべてのパッケージを SwiftPM で導入することができました。 この状態でiOSシミュレータ向けのビルドを行い、その時間を計測した結果が下表です。やはり依存パッケージに関する処理に多くの時間がかかっていることがわかりましたので、ここから少しずつ工夫して削減していこうと思います。 ビルド処理 時間 🍎 SwiftPM 依存パッケージの解決 3分 🍋 SwiftPM 依存パッケージのビルド 🍊アプリのビルド ※ 5分 ⏳ 計 8.00分 ※ Xcode は SwiftPM 依存パッケージやアプリそのもののビルドも並列で行っているようなので、パッケージとアプリのビルドは1つにまとめられています。 SwiftPM でパッケージのソースをキャッシュする Xcode から SwiftPM を利用する場合、GitHub等からクローンしてきた依存パッケージのソースコードを再利用できるようになっています。CIサービスにはたいていキャッシュする機能が用意されていますので、これを利用して依存パッケージのソースコードをキャッシュします。具体的な方法は以下の Qiita の記事にまとめていますのでご参照ください。 qiita.com この方法により、依存関係の解決やクローンの処理は初回のみで、以降はキャッシュを利用してその処理を丸ごとスキップできるようになりました! ビルド処理 時間 🍎 SwiftPM 依存パッケージの解決 (キャッシュなし) 3分 🍋 SwiftPM 依存パッケージのビルド 🍊アプリのビルド 5分 ⏳ 計 (キャッシュなし) 8分 (キャッシュあり) 5分 できれば依存パッケージをビルドした成果物もキャッシュできると良いのですが、Qiita の記事にも書きましたとおり、現時点ではビルド成果物をキャッシュすることはできません。 Carthage でパッケージのビルド成果物をキャッシュする パッケージのビルド成果物もキャッシュできるとさらなる時間短縮が見込めます。ビルド成果物をキャッシュするためにはダイナミックリンクできる Binary Framework 形式でのビルドが必要となります。 現時点では Carthage か CocoaPods のプラグイン *6 を使用することで Binary Framework 形式でビルドすることができますが、Apple Silicon を搭載した Mac でも利用できる新しい Binary Framework 形式である XCFramework *7 に対応しているのは Carthage のみです。よって、できる限り Carthage でパッケージを導入するように変更し、ビルドした XCFramework ファイルを CI でキャッシュするように設定します。 Firebase iOS SDK はしばらくの間 Experimental という扱いで Carthage によるインストールに対応していたのですが、XCFramework への対応をきっかけに Carthage での提供は継続しないという宣言がありました(2020年12月) *8 。つまり、Firebase iOS SDK は Carthage でインストールすることはできませんので、それ以外のパッケージのみ Carthage に変更しました。 (2021年5月28日 追記) 2021年5月に Firebase iOS SDK の新しいメジャーバージョン 8.0.0 がリリースされました。Carthage が XCFramework 形式に対応されたため、このバージョンから Carthage での提供が再開されたようです。 ただし、8.0.0 では Cloud Firestore が依存する gRPC-C++.xcframework がインストールされない問題 *9 がありますのでご注意ください。 その結果、SwiftPM 依存パッケージのビルド時間が1分ほど減りましたが、これは思っていたほどの効果ではありませんでした。Firebase iOS SDK が他のパッケージに比べて圧倒的に大きく、ビルド時間の大半を占めているということですね。 ビルド処理 時間 🥝 Carthage 依存パッケージの解決/ビルド (初回のみ) 17分 🍎 SwiftPM 依存パッケージの解決/クローン (初回のみ) 2分 🍋 SwiftPM 依存パッケージのビルド 🍊アプリのビルド 4分 ⏳ 計 (キャッシュなし) 23分 (キャッシュあり) 4分 また、初回のみとはいえ Carthage のビルド時間は非常に多くの時間がかかってしまう(1パッケージあたり平均1分くらい)のも気になります。もちろん、 --platform iOS オプションにより iOS 向けのビルドに限定するなど、最低限のビルドに抑えていてこの状態です。 iOSデバイス用・シミュレータ用など必要なバイナリを全てビルドする必要があることが原因なのでしょうか。せめて複数のパッケージを並列でビルドする機能があれば良いのですが、現時点では対応されていないようです *10 。 対して SwiftPM は Xcode 10 で導入された New Build System により適切に並列ビルドしてくれるので、Carthage に比べてビルド時間が短くなっています。 Carthage で不要なパッケージのビルドをスキップする Carthage はパッケージのXcodeプロジェクトに含まれる全ての共有スキームをビルドします。そのため、アプリから使用しないスキームもビルドすることになり、無駄な時間がかかってしまいます。この不要なビルドを次の方法でスキップすることでビルド時間を少し抑えることができます。 qiita.com 今回のケースでは6つの XCFramework のビルドをスキップすることができ、Carthage のビルド時間が6分減りました。 ビルド処理 時間 🥝 Carthage 依存パッケージの解決/ビルド (初回のみ) 11 🍎 SwiftPM 依存パッケージの解決 (初回のみ) 2 🍋 SwiftPM 依存パッケージのビルド 🍊アプリのビルド 4 ⏳ 計 (キャッシュなし) 17分 (キャッシュあり) 4分 Firebase iOS SDK のビルド済み XCFramework をマニュアルインストールする さて、最も多くのビルド時間が費やされている Firebase iOS SDK の工夫にとりかかります。Firebase iOS SDK の GitHub リポジトリのリリースページでは、各リリースに対してビルド済みの XCFramework がまとめられた Firebase.zip が添付されています *11 。 このファイルはバージョン 7.11.0 で 300MB もありますので、アプリのリポジトリに追加するには少し大きすぎます。そこで、このファイルをダウンロード・展開・キャッシュするフローをCI上で行うようにします( curl や unzip などのコマンドを組み合わせた簡単なスクリプトで自動化できますね)。 CIサービス上であれば高速なネットワーキングにより30〜60秒程度でダウンロードと展開が終わってしまいますので、初回のキャッシュにかかる時間も最小限で済みます。 この結果、アプリのビルド時間は 1.5 分までに縮まりました!最初は 8 分かかっていたので 80%も削減 できたことになります 🎉 ビルド処理 時間 🥝 Carthage依存パッケージの解決とビルド (初回のみ) 11分 🔥 Firebase iOS SDKのマニュアルインストール (初回のみ) 1分 🍋 SwiftPM 依存パッケージのビルド 🍊 アプリのビルド 1.5分 ⏳ 計 (キャッシュなし) 13.5分 (キャッシュあり) 1.5分 Carthage のビルド時間が気になる場合は、Firebase iOS SDK 以外は SwiftPM でインストールするように戻しましょう。SwiftPM で導入したパッケージは毎回ビルドする必要がありますが、SwiftPM での並列ビルドが高速なので +0.5分 程度の増加のみでした。開発環境をシンプルに保ちたい場合はこちらの方が良さそうですね。 ビルド処理 時間 🔥 Firebase iOS SDKのマニュアルインストール (キャッシュなし) 1分 🍎 SwiftPM依存パッケージの解決 (キャッシュなし) 2分 🍋 SwiftPM/依存パッケージのビルド 🍊アプリのビルド 2分 ⏳ 計 (キャッシュなし) 5分 (キャッシュあり) 2分 さいごに 今回は弊社の提供するiOSアプリを例に、依存パッケージのビルド時間を削減していく過程をお伝えしました。依存パッケージやCI環境によって最適な方法は異なると思いますが、工夫のポイントなどで参考になれば幸いです。 最後になりますが、スタメンでは自社プロダクトの開発する仲間を募集しています。興味を持ってくれた方は、ぜひ下記の採用サイトをご覧ください。 スタメン エンジニア採用サイト デザイナー募集ページ サーバーサイドエンジニア募集ページ フロントエンドエンジニア募集ページ インフラエンジニア募集ページ モバイルアプリエンジニア募集ページ *1 : CocoaPods : CocoaPods is a dependency manager for Swift and Objective-C Cocoa projects. *2 : Carthage : Carthage is intended to be the simplest way to add frameworks to your Cocoa application. *3 : Swift Package Manager : The Swift Package Manager is a tool for managing the distribution of Swift code. *4 : https://firebase.google.com/docs/ios/setup?hl=ja *5 : https://github.com/firebase/firebase-ios-sdk/blob/master/SwiftPackageManager.md *6 : CocoaPods Binary ・ CocoaPods Rome : どちらのプラグインも XCFramework への対応は進んでいません。 *7 : Apple Developer / Distributing Binary Frameworks as Swift Packages *8 : firebase-ios-sdk / WARNING: Carthage May Be Discontinued *9 : Firestore Carthage installation is missing gRPC-C++ in 8.0.0 *10 : Carthage / Parallel building #1104 *11 : https://github.com/firebase/firebase-ios-sdk/releases : Xcodeプロジェクトへの組み込みは、ZIPファイル内にある README.md に記載されています。
目次 はじめに Reactを使用したフォーム設計パターンについて React Hook Formとは ? React Hook Formの基本機能の紹介 React Hook Formのユースケース 最後に はじめに こんにちは、株式会社スタメンでエンジニアをしています、 ワカゾノ です。 Rails、Reactを使用して、弊社プロダクト TUNAG の機能開発を行っています。 直近のプロジェクトにおいて、Reactでフォームを実装する必要がありました。 要件としては、下記のようになります。 新規作成時、編集時のフォームをerbから、Reactへリプレイス 1画面毎に3 ~ 6つのフォームが存在、それを10数画面分実装 各フォームの入力値に応じて画面の表示を動的に変更する 例) 選択しているラジオボックスにより、フォーム要素の表示、非表示を切り替える 各フォームに細かいバリデーションが必要 例 ) セレクトボックスの組み合わせによっては、同時に選択できない プロジェクト自体はReduxを使用してstateの管理を行っているため、 onChangeイベントを用いて、フォームの入力を元にUIに関するstateを管理することも出来ます。 しかしフォームの規模が大きいほど、UIに関連するstate管理が煩雑になり、stateを管理するための定型コードの記述量が増えるという問題があります。 また入力の度にレンダリングが走り、パフォーマンスの問題も懸念されます。 上記のような問題点を解消するために、 React Hook Form というライブラリを使用してフォーム実装を行うことになりました。 今回は「React Hook Formとは」、「React Hook Formの実際の使い方」について紹介していこうと思います。 Reactを使用したフォーム設計のパターンについて React Hook Formの説明に入る前に、Reactを使用したフォーム設計パターンについて紹介します。 Reactではフォーム実装において、2つのパターンが存在します。 Controlled Component Reactのstateを唯一信頼出来る情報源(single source of truth)とし、フォームをレンダーしているReactコンポーネントが、後続のユーザー入力でフォームに起こるイベントを制御する Uncontrolled Component フォームデータをDOM(ブラウザ)自身が制御する そもそもHTMLでは input 、 textarea 、 select のようなフォーム要素は自身で状態を保持しています。 Uncontrolled Componentによるフォーム構築は、React固有の実装というよりは、ネイティブ(ブラウザ)の実装に近い形になります。 React公式 では、Controlled Componentの使用が推奨されています。 React + Reduxの環境下で、Controlled Componentのライブラリとして有名な redux-form では、Reduxでの状態管理を元にフォーム構築を行います。 しかし、 Redux公式 では、「経験則に基づくと、Reduxでフォームの状態を管理する必要はないと考えられる」という記載があります。 これらの主張に則れば、「フォームに関する状態をReduxで管理せず、useStateなどのローカルstateを使用して、Controlled Componentパターンでフォームコンポーネントを実装する」という手法がベストプラクティスのように思われます。 しかし、上述したような複雑なフォームのstate管理が必要かつ、大規模なフォームを実装する上で、ローカルstateだけでフォームを実装することは大変です。 そこでReact Hook Formが登場します。 React Hook Formとは ? 公式サイト では、「シンプルかつ、拡張性のある、使い勝手の良いフォームバリデーションライブラリ」という説明がされています。 Performant, flexible and extensible forms with easy-to-use validation. React Hook Formは、React16.8.0から導入されたhooksの仕組みを利用したフォームライブラリです。 Uncontrolled Componentsのパターンを採用しており、フォーム毎の参照( ref )をカスタムフックス( useForm )に登録することで、フォームの状態をコントロールします。 useForm が提供するAPIである、 register を使用して、各入力フォームの要素の参照を登録します。 React Hook Formの利点として、公式で紹介されているものとしては以下のような点が挙げられます。 state管理などのコードの記述量を減らすことが出来る パッケージが軽量 Unontrolled Componentsのパターンを採用しており、レンダリング回数を減らすことが出来る 今回の要件で言えば、特に下記のような問題点に対して、アプローチ出来るため、React Hook Formを選定するに至りました。 10数画面分のフォームを実装するにあたり、各フォームで入力変更を検知するstate、アクションなどを定義していくことが大変 単純なコードの記述量が増える メンテナンス性が低下する テキストエリアなど、長い文章を入力する際にレンダリング回数を減らすことが出来る それでは実際にコードを書いていきながら紹介していきます。 React Hook Formの基本機能の紹介 簡単なデモを作成して、基本機能を紹介していきます。 動作環境は下記になります。 node v12.16.2 yarn v1.22.5 react v17.0.2 typescript v4.1.2 react-hook-form v7.1.1 create-react-appにて新規Reactプロジェクトを作成し、 yarn、npmなどのパッケージマネージャを使用して、 React Hook Formをプロジェクトにインストールします。 npx create-react-app react-hook-form-sample --template typescript yarn add react-hook-form 画像のような簡単な入力フォームを実装し、React Hook Formについて説明していきます。 SampleForm.tsx import React from 'react' import { useForm, SubmitHandler, SubmitErrorHandler } from 'react-hook-form' type ValuesType = { name: string, introduction: string, department: 'product' | 'sales' | 'marketing' | '' programingLanguage: 'golang' | 'ruby' | 'javascript' | '' } const SampleForm: React.VFC = () => { const { register, watch, handleSubmit, formState } = useForm<valuesType>( { mode: 'onSubmit' , reValidateMode: 'onChange' , defaultValues: { name: '' , introduction: '' , department: '' , programingLanguage: '' } } ) const handleOnSubmit: SubmitHandler<valuesType> = (values) => { console.log(values) } const handleOnError: SubmitErrorHandler<valuesType> = (errors) => { console.log(errors) } return ( <wrapper> <form onSubmit= { handleSubmit(handleOnSubmit, handleOnError) } > // テキスト項目 <label htmlFor= 'name' >Name</label> { !!formState.errors.name && <p> { formState.errors.name.message } </p> } <input id= 'name' type= "text" isError= { !!formState.errors.name } // エラー時にborderの色を変更するためのprops { ...register( 'name' , { required: '* this is required filed' } ) } /> // テキストエリア項目 <label htmlFor= 'introduction' >Introduction</label> { !!formState.errors.introduction && <p> { formState.errors.introduction.message } </p> } <textarea id= 'introduction' isError= { !!formState.errors.introduction } { ...register( 'introduction' , { required: '* this is required filed' , minLength: { value: 10, message: '* please enter at least 10 characters' } } ) } /> // セレクトボックス <label htmlFor= 'department' >Department</label> { !!formState.errors.department && <p> { formState.errors.department.message } </p> } <select id= 'department' isError= { !!formState.errors.department } { ...register( 'department' , { required: '* this is required filed' } ) } > <option value= '' hidden>please selecting...</option> <option value= 'product' >Product</option> <option value= 'sales' >Sales</option> <option value= 'marketing' >Marketing</option> </select> // セレクトボックス { watch( 'department' ) === 'product' && <> <label htmlFor= 'programing-langage' >Programing Language</label> <select id= 'programing-language' { ...register( 'programingLanguage' ) } > <option value= '' hidden>please selecting...</option> <option value= 'golang' >Golang</option> <option value= 'ruby' >Ruby</option> <option value= 'javascript' >Javascript</option> </select> </> } // 送信ボタン <button type= "submit" disabled= { !formState.isDirty || formState.isSubmitting } > Click </button> </form> </wrapper> ) } useForm useForm ではオプショナルの引数を渡すことで、フォーム全体のバリデーションのタイミングを制御したり、フォームの初回レンダリング時のデフォルト値を設定することができます。 const { register, watch, handleSubmit, formState } = useForm<valuesType>( { mode: 'onSubmit' , // バリデーションが実行されるタイミング reValidateMode: 'onChange' , // 再度バリデーションを実行するタイミング、onChangeの場合は、入力の度にバリデーションが走る defaultValues: { // 初回レンダリング時のフォームのデフォルト値 name: '' , introduction: '' , department: '' , programingLanguage: '' } } ) mode で onChange を指定することは、 this often comes with a significant impact on performance と記載があるようにパフォーマンスへの懸念から推奨されていません。 register input や select 要素をReact Hook Formのバリデーションルールに適用するために、このメソッドを使用します。 第1引数に、登録する参照の名前を設定します。 設定方法によって入力結果をネストしたり、配列で渡すことができます。 register( "name" ) 👉 { name: 'value' } register( "name.firstName" ) 👉 { name: { firstName: 'value' } } register( "name.firstName.0" ) 👉 { name: { firstName: [ 'value' ] } } 第2引数にバリデーションのルールをオブジェクトの形式で渡します。 今回だと required(必須) 、 minLength(最小文字数) を使用しています。 複雑なバリデーション要件が必要になってくる場合などは、 validate オプションを使用すると良さそうです。 handleSubmit 第1引数に、バリデーション成功時のコールバック関数を、第2引数に、エラー時(バリデーションに引っかかった際)のコールバック関数を登録することができます。 今回は成功時のコールバック関数で、フォームからの入力値を受け取りコンソールに出力しています。渡ってくるデータは下記のようになります。 { name: "Takuya Wakazono" , introduction: "I Like React Hook Form So Much!!" , department: "product" , programingLanguage: "javascript" } 失敗時のコールバック関数ではエラーを内容を受け取ることができます。 渡ってくるデータとしては下記のようになります。(すべて未入力の場合) { name: { type: "required" , message: "* this is required field" , ref: "..." } , introduction: { type: "required" , message: "* this is required field" , ref: "..." } , department: { type: "required" , message: "* this is required field" , ref: "..." } , } formState フォーム全体に関するstate(状態)をオブジェクト形式で保持しています。 今回であれば、 isDirty や errors 、 isSubmitting 等が該当します。 isDirty: input要素に入力が合った場合はtrueを返す(ユーザーが何も入力していない場合はfalseのまま) errors: エラーオブジェクトを格納 isSubmitting: 送信中かどうかを判定 watch 入力値を監視し、その値を返します。 主に入力値に応じてフォームのUIを動的に変更する場合などに使用します。 今回はDepertmentを選択する際に、 product を選択した場合のみ、プログラミング言語を選択するフォームをレンダリングするようにしています。 React Hook Formのユースケース これまでにReact Hook Formの基本的な機能を紹介しましたが、 実際は一つのコンポーネント内にフォームをベタに書くことはほとんど無く、 テキストフォーム、テキストエリア、チェックボックスなどの汎用コンポーネントをimportして、フォームコンポーネントを構築することがほとんどであると思います。 そのような場合に、 FormProvider 、 useFormContext を使用して、registerをpropsとして汎用コンポーネントに渡すことで、フォームを構築することが可能です。 pages/components/common/index.tsx import React from 'react' import { UseFormRegisterReturn } from 'react-hook-form' type PropsType = { labelName: string register: UseFormRegisterReturn } export const TextInput: React.VFC = (props: PropsType) => { const { id, labelName, register } = props return ( <> <label htmlFor= { id } > { labelName } </label> <Input id= { id } type= "text" { ...register } /> </> ) } export const Textarea: React.VFC = (props: PropsType) => { const { id, labelName, register } = props return ( <> <label htmlFor= { id } > { labelName } </label> <textarea id= { id } { ...register } /> </> ) } export const SelectBox: React.VFC = (props: PropsType) => { const { id, labelName, register } = props return ( <> <label htmlFor= { id } > { labelName } </label> <select id= { id } { ...register } > <option>選択肢1</option> <option>選択肢2</option> </select> </> ) } export const SubmitButton: React.VFC = () => { return ( <input type= "submit" /> ) } SampleForm.tsx import React from 'react' // 汎用コンポーネントのimport =========================== import NestedSampleForm from './' import { FormProvider, useForm } from 'react-hook-form' type ValuesType = { // ... } const SampleForm: React.VFC = () => { const methods = useForm<ValuesType>() return ( <FormProvider { ...methods } > <Form onSubmit= { handleSubmit(handleOnSubmit) } > <NestedSampleForm /> </Form> <SubmitButton /> </FormProvider> ) } NestedSampleForm.tsx import React from 'react' import { useFormContext } from 'react-hook-form' // 汎用コンポーネントのimport =========================== import { TextInput, Textarea, SelectBox } 'pages/components/common' // ================================================= const NestedSampleForm: React.VFC = () => { const { register } = useFormContext<valuesType>() return ( <Wrapper> <TextInput labelName= 'テキスト項目' register= { register( 'text' ) } /> <TextareaForm labelName= 'テキストエリア項目' register= { register( 'textarea' ) } /> <SelectBox labelName= 'セレクトボックス' register= { register( 'selectbox' ) } /> </Wrapper> ) } 最後に フォームの要件が複雑になるほど、Reactが推奨しているControlled Componentのパターンでは、state管理が大変になり、辛さを感じていたので、シンプルで使いやすいというのはまさにその通りだなと思いました。 今回紹介した以外にも、たくさん機能があるので、今後React Hook Formを使用していく中で、応用的な使用方法など知見が溜まった際は、また紹介させて頂こうと思います! スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。 エンジニア募集ページ
はじめに 背景 ActiveRecord::AttributeMethods::Dirtyとは メソッド一覧 メソッド名の変遷 活用に向けた検証 検証に使用したモデル Dirtyの活用例 実現したかったこと/実装例 Dirtyの活用したサンプルコード おわりに 参考 はじめに はじめまして、スタメンでエンジニアをしているショウゴです。普段は、バックエンドグループでRuby on Railsを用いてバックエンドの開発を主に担当しています。 今回の記事では、ActiveRecordのattributeの変更状況を確認できるRailsのActiveRecord::AttributeMethods::Dirtyモジュールの使い方の検証結果と活用例を紹介します。 背景 今回、特定のカラムの値を変化させて、ステータスの変更・管理を行っているモデルに対して新たなバリデーションを実装する作業の中で、特定のカラムの変化を察知し、特定のステータス変化が発生する時にだけバリデーションを実行するように実装する必要がありました。そのため、特定のカラムの変更状況の確認と変更前後の値の取得を行うために、ActiveRecord::AttributeMethods::Dirtyモジュールを活用しました。 ActiveRecord::AttributeMethods::Dirtyとは Dirtyは、オブジェクトに変更があった場合に検出ができ、変更前後の値を取得することができます。 使用できるメソッドは下記の通りです。 メソッド一覧 method一覧 用途 changed_attribute_names_to_save 保存予定の変更があるカラム名 has_changes_to_save? 保存予定の変更があるか判定 changes_to_save 保存予定の変更があるカラム名と変更前後の値 カラム名_change_to_be_saved 特定のカラムの保存予定の変更前後の値 will_save_change_to_attribute?(カラム名, from: "hoge", to: "fuga")(※2) 保存予定の変更があるか判定、変更前後の値を指定可 カラム名_in_database 特定のカラムのDBの値 attributes_in_database 全てのカラムの名前とそれらのDBの値 カラム名_before_last_save 特定のカラムの直近の保存前の値 saved_change_to_カラム名 直前に保存された変更内容 saved_change_to_カラム名?(from: "hoge", to: "fuga")(※1) 直前に保存された変更があるか判定、変更前後の値を指定可 saved_changes?() 直前に保存で値の変更があったか判定 saved_changes() 直前に保存した変更の変更前後の値 ※1 : saved_change_to_attribute?(:カラム名, from: "hoge", to: "fuga")とも書けます。 ※2 : will_save_change_to_カラム名?(from: "hoge", to: "fuga")でも書けます。 メソッド名の変遷 Railsの旧バージョンでは、下記のメソッドが用意されていましたが、現在ではそれらは非推奨となり、より分かりやすい表現に変わっています。メソッドの数が増えていますがafter_create/after_updateの前後のどちらかということを意識しながら過去形、現在形、未来形の時制に注目することがポイントです。 # 注)以下は現在非推奨です。 attribute_changed? attribute_change attribute_was changes changed? changed changed_attributes 活用に向けた検証 検証に使用したモデル Rails ver.6.0.3.5において、検証用に下記のモデルを準備しました。 DBのカラムに対するデフォルト値(以下、初期値)の設定の有無の影響を再現するため、初期値が無いnameカラムと初期値があるstatusカラムを用意しました。 # == Schema Information # # Table name: users # # id :bigint not null, primary key # name :string(255) # status :integer default("active"), not null # created_at :datetime not null # updated_at :datetime not null # class User < ApplicationRecord enum status : { active : 0 , inactive : 1 , inviting : 2 } end createとupdateの過程における挙動を確認した結果が下記になります。 > user1 = User .new( name : " Tom " ) => #<User id: nil, name: "Tom", status: "active", created_at: nil, updated_at: nil> > { changed_attribute_names_to_save : user1.changed_attribute_names_to_save, has_changes_to_save? : user1.has_changes_to_save?} => { :changed_attribute_names_to_save =>[ " name " ], :has_changes_to_save? => true } > { changes_to_save : user1.changes_to_save, name_change_to_be_saved : user1.name_change_to_be_saved} => { :changes_to_save =>{ " name " =>[ nil , " Tom " ]}, :name_change_to_be_saved =>[ nil , " Tom " ]} > " will_save_change_to_attribute?(:name, from: nil, to: 'Tom')=> #{ user1.will_save_change_to_attribute?( :name , from : nil , to : " Tom " ) }" => " will_save_change_to_attribute?(:name, from: nil, to: 'Tom')=>true " > user1.save => true > user1.name = " Jelly " => " Jelly " > { attributes_in_database : user1.attributes_in_database, name_in_database : user1.name_in_database} => { :attributes_in_database =>{ " name " => " Tom " }, :name_in_database => " Tom " } > user1.save => true > { name_before_last_save : user1.name_before_last_save, saved_change_to_name : user1.saved_change_to_name} => { :name_before_last_save => " Tom " , :saved_change_to_name =>[ " Tom " , " Jelly " ]} > " saved_change_to_name?(from: 'Tom', to: 'Jelly')=> #{ user1.saved_change_to_name?( from : " Tom " , to : " Jelly " ) }" => " saved_change_to_name?(from: 'Tom', to: 'Jelly')=>true " > { saved_changes? : user1.saved_changes?, saved_changes : user1.saved_changes} => { :saved_changes? => true , :saved_changes =>{ " name " =>[ " Tom " , " Jelly " ], " updated_at " =>[ Tue , 06 Apr 2021 11 : 45 : 23 UTC + 00 : 00 , Tue , 06 Apr 2021 11 : 45 : 47 UTC + 00 : 00 ]}} 上記の挙動確認の結果より、before_create, before_updateのタイミングで、特定のカラムの変更前後を確認するには、 カラム名_change_to_be_saved が最も良いのではないかと当初は考えました。 しかし、実装の過程で初期値がある場合と初期値が無い場合で、下記の様に少し挙動が異なることが分かりました。 > user1 = User .new( name : " Tom " ) # 初期値なしの場合 => #<User id: nil, name: "Tom", status: "hoge", created_at: nil, updated_at: nil> > user1.name_change_to_be_saved => [ nil , " Tom " ]  # nil -> "Tom" > user2 = User .new( status : 0 ) # 初期値あり、初期値に設定する場合 => #<User id: nil, name: nil, status: "active", created_at: nil, updated_at: nil> > user2.status_change_to_be_saved => nil   # nil -> "active"ではなく 変更なしと判断されnilが返る > user3 = User .new( status : 1 ) # 初期値あり、初期値以外に設定する場合 => #<User id: nil, name: nil, status: "inactive", created_at: nil, updated_at: nil> > user3.status_change_to_be_saved => [ " active " , " inactive " ]   # nil -> "inactive"ではなく "active" -> "inactive" > user4 = User .new( status : 2 ) # 初期値あり、初期値以外に設定する場合 => #<User id: nil, name: nil, status: "inviting", created_at: nil, updated_at: nil> > user4.status_change_to_be_saved => [ " active " , " inviting " ]   # nil -> "inviting"ではなく "active" -> "inviting" 初期値が無い場合は、nilから設定値に変化するのですが、初期値がある場合は、nilではなく初期値から設定値に変化するという挙動になることが分かりました。また、 初期値がある場合に カラム名_change_to_be_saved を使うと設定値が初期値と同等の場合はnilが返り、設定値が初期値以外の場合は配列が返るため、nilの場合と配列の場合を判定仕分ける必要が出てきました。 Dirtyの活用例 実現したかったこと/実装例 今回の実装で実現したかったことに対して実装した内容が下記の通りです。 バリデーションエラーのメッセージを分けるため、onオプションでバリデーションを分けたい # 抜粋 validate :validate_registable_user_condition , on : :create , if : -> { will_add_registered_user? } validate :validate_updatable_user_condition , on : :update , if : -> { will_add_registered_user? } def validate_registable_user_condition # createのバリデーション if can_not_registable? errors.add( :base , " ユーザー数が多すぎるため、ユーザーを新規登録できません。 " ) # エラーメッセージ 1 end end def validate_updatable_user_condition # updateのバリデーション if can_not_updatable? errors.add( :base , " ユーザー数が多すぎるため、ユーザーを更新できません。 " ) # エラーメッセージ 2 end end バリデーションが必要か否かを判定するメソッドはcreateとupdateで共通としたい。 # 抜粋 def will_add_registered_user? # create/update共通のバリデーション要否の判定メソッド if new_record? # createの場合 # ~中略~ else not_registerd_user? && will_save_change_to_registerd_user? # updateの場合 end end status: active, inviting で新規作成する場合は、バリデーション対象としたい。 status_change_to_be_saved メソッドを使わずにcreateのsave直前の値を確認したい。 # 抜粋 def will_add_registered_user? if new_record? # createの場合 self[:status] == "active" || self[:status] == "inviting" # save直前の値をチェック else # ~中略~ end end active→inviting, inviting→activeの更新はバリデーション対象から除外したい。 # 抜粋 def will_add_registered_user? if new_record? # ~中略~ else # active→inviting, inviting→activeの更新か否かを判定 not_registerd_user? && will_save_change_to_registerd_user? end end def registered_user_in_database # 更新前のstatusカラムのDBの値をチェック status_in_database == ' active ' || status_in_database == ' inviting ' end # statusがactive, inviting以外の場合か否か判定 def not_registerd_user? !registered_user_in_database end # statusがactiveもしくはinvitingへの更新か否か判定 def will_save_change_to_registerd_user? will_save_change_to_attribute?( :status , to : " active " ) || will_save_change_to_attribute?( :status , to : " inviting " ) end Dirtyの活用したサンプルコード 検証の結果を踏まえて、下記のサンプルコードのように実装することでバリデーション対象の状態変化か否かを判定できる様になりました。 class User < ApplicationRecord enum status : { active : 0 , inactive : 1 , inviting : 2 } validate :validate_registable_user_condition , on : :create , if : -> { will_add_registered_user } validate :validate_updatable_user_condition , on : :update , if : -> { will_add_registered_user } def validate_registable_user_condition if can_not_registable? errors.add( :base , " ユーザー数が多すぎるため、ユーザーを新規登録できません。 " ) end end def validate_updatable_user_condition if can_not_updatable? errors.add( :base , " ユーザー数が多すぎるため、ユーザーを更新できません。 " ) end end # status: active, invitingのユーザーはカウント対象となる。 def will_add_registered_user? if new_record? # status: active, inviting で新規作成する場合 self [ :status ] == " active " || self [ :status ] == " inviting " else # activeもしくはinvitingから登録対象にカウントされる状態へ更新する場合 not_registerd_user? && will_save_change_to_registerd_user? end end def registered_user_in_database status_in_database == ' active ' || status_in_database == ' inviting ' end def not_registerd_user? !registered_user_in_database end def will_save_change_to_registerd_user? will_save_change_to_attribute?( :status , to : " active " ) || will_save_change_to_attribute?( :status , to : " inviting " ) end # 〜中略〜 end おわりに 今回は、ActiveRecord::AttributeMethods::Dirtyモジュールの活用方法について紹介させていただきました。 今回の紹介した内容が少しでも参考になれば幸いです。 スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。 Webアプリケーションエンジニア募集ページ 参考 ActiveRecord::AttributeMethods::Dirty
こんにちは。フロントエンドエンジニアの 渡邉 です。 普段はReactとTypeScriptを書いています。 今回は自分がコンポーネントを実装する際に意識していることについていくつか紹介できればなとおもいます。 ※ スタイリングに関して話すときはstyled-componentsを使用しています。 目次 はじめに 再利用性の高いコンポーネントを実装するために意識していること 共通のコンポーネントを作成する際は汎用性を意識する コンポーネントが知らなくてもいい情報を持たない(コンポーネント・構成編) コンポーネントが知らなくていい情報をもたない(コンポーネント・スタイル編) 無駄な描画を減らすために意識していること 状態に関係ないコンポーネントを混ぜない さいごに はじめに 今まで自分がReactを書いてきて、再利用性が低いコンポーネントを実装してしまったり、コンポーネントの設計自体が無駄な再描画を起こしてしまうことがあったので、その過ちを起こさないためにも実装する際に自分が意識していることを悪い例・良い例と比べながら紹介します。 この記事を読んだ後に得られる知見としては以下の2つです。 再利用性の高いコンポーネントが実装できる 無駄な再描画を可能な限り減らせたコンポーネントの実装(memo化などを使わずに) 再利用性の高いコンポーネントを実装するために意識していること 共通のコンポーネントを作成する際は汎用性を意識する 共通のコンポーネントの例としてButtonコンポーネントを作るとします。 ここで意識しているのは、共通のコンポーネント(子コンポーネント)に、呼び出し側のコンポーネント(親コンポーネント)を依存させることです。 悪い例 interface ButtonInterface { title: string getUserData: () => void } export const Button = ( { title , getUserData } : ButtonInterface ) => { return ( // getUserDataを実行するだけのボタンになってしまっている // ただ、getUserDataというpropsで違う振る舞い(会社情報を取得)をすることも可能だが、このような使い方は負債の原因となる < StyledButton onClick = { getUserData } > { title } < /StyledButton > ) } const StyledButton = styled.button ` background-color: red; ` //... // 親コンポーネント const User = () => { const getUserData = () => { //... } const getComapanyData = () => { //... } return ( < div > < Button title = 'ユーザー情報取得' getUserData= { getUserData } / > { /* これでもちゃんと動きますが、上記で説明したとおり負債の原因となる */ } < Button title = 'ユーザー情報取得' getUserData= { getComapanyData } / > < h1 > ユーザー情報 < /h1 > // ... < /div > ) } 上記だとユーザー情報を取得するためだけのボタンになってしまっています。 良い例 interface ButtonInterface { title: string onClick : ( e: React.MouseEvent < HTMLButtonElement >) => void } export const Button = ( { title , onClick } : ButtonInterface ) => { return ( < StyledButton onClick = { onClick } > { title } < /StyledButton > ) } const StyledButton = styled.button ` background-color: red; ` //... // 親コンポーネント const User = () => { const getUserData = () => { //... } const getComapanyData = () => { //... } return ( < div > < Button title = 'ユーザー情報取得' onClick = { getUserData } / > < Button title = 'ユーザー情報取得' onClick = { getComapanyData } / > < h1 > ユーザー情報 < /h1 > // ... < /div > ) } ボタンが押されたときの振る舞いを実行するだけです。 まとめ 共通のコンポーネントを作成する際は、親コンポーネントに依存したコンポーネントを作らないようにします 親コンポーネントに共通コンポーネントに依存させます 親に依存した時点で依存元のコンポーネントで作成します 何にも依存していない場合 : components/common/Button.tsx ユーザーに依存している場合: components/user/Button.tsx コンポーネントが知らなくてもいい情報を持たない(コンポーネント・構成編) 親コンポーネントは子コンポーネントのことをしるべきではないです。 逆も然りで子コンポーネントは親コンポーネントを知るべきではないです。 知ってしまった時点で再利用性は低くなります。 悪い例 const TodoList = () => { return ( < ul > { todo.map ( item => ( < Item key = { item. id } item = { item } / > )) } < /ul > ) } const Item = ( { item } ) => { return ( < li > < p > { item. title } < /p > < /li > ) } 最終的に表示されるのは ul の中にtodoの個数分 li が表示されます。 これの何が悪いのかというと、TodoListコンポーネントはItemコンポーネントがliを返すことをしっているから、ulの中に含めることができています。 つまり、ItemコンポーネントはTodoList専用のコンポーネントになります。 もしItemコンポーネントを他の場所かつ単体で使いたい場合は以下のようになり、 <div><li></li></div> というよろしくない構成になってしまいます。 const AnotherComponent = () => { return ( < div > < h1 > AnotherComponent < /h1 > < Item item = { item } / > < /div > ) } そのため、コンポーネントが知らなくてもいい情報を持たないのが大事です。 下記が適用したコードになります。 良い例 const TodoList = () => { return ( < ul > { todo.map ( item => ( < li key = { item. id } > < Item item = { item } / > < /li > )) } < /ul > ) } const Item = ( { item } ) => { return ( < div > < p > { item. title } < /p > < /div > ) } これで親コンポーネントと子コンポーネントはお互いのことを知らなくなりました。 コンポーネントが知らなくていい情報をもたない(コンポーネント・スタイル編) スタイルに関しても知らなくてもいい情報を知ってしまうと、再利用性が低くなってしまいます。 例えば、アイコンがいくつか並んだコンポーネントがあるとします。 悪い例 const Icon = ( { src } ) => { return ( < Img src = { src } / > ) } const Img = styled.img ` margin: 0 10px; ` アイコンのコンポーネントは上記のように定義されてあり、他の箇所でこのコンポーネントを使いたくなったとします。 今回は左右に20px必要です。このときにどのように解決すればよいでしょうか。 コンポーネント内に条件を追加してスタイリングをするなど様々な解決方法がありますが、、知らなくていい情報を持つことによって分岐が増えて可読性が下がります。 良い例 const Icon = ( { src } ) => { return ( < img src = { src } / > ) } const IconList = () => { return ( < IconWrapper > < Icon src = { //...} /> < Icon src = { //...} /> < Icon src = { //...} /> < /IconWrapper > ) } const IconWrapper = styled.div ` // アイコンのレイアウト記載 ` 子コンポーネントはどのように配置されるかを知らないようにします。 親がどのように配置するかを考えます。 まとめ 基本的にコンポーネントのトップでmarginを持たせないようにします 子コンポーネントは親のレイアウトを知るべきではないです 親も子の見た目について知らないようにします 無駄な描画を減らすために意識していること 状態に関係ないコンポーネントを混ぜない 状態に関係ないコンポーネントを混ぜてしまうことによって、無駄な再描画が起きてしまいます。 React.memo()でも防げますが、React.memo()をしないで防ぐのがベストだと思います。 状態の管理をReact.useStateを使っている場合と、Reduxで管理している場合の2つのパターンで紹介します。 悪い例 const Hoge = () => { const [ count , setCount ] = useState ( 0 ) return ( < div > < Counter count = { count } setCount = { setCount } / > < AnotherComponent / > < /div > ) } AnotherComponent コンポーネントは count という状態に関係ないのにも関わらずcountに変更があるたびに再描画されてします。 良い例 const Hoge = () => { return ( < div > < Counter / > < AnotherComponent / > < /div > ) } const Counter = () => { const [ count , setCount ] = useState ( 0 ) // ... } 正しい箇所で状態を管理します。 Reduxを使っている場合 悪い例 const Hoge = () => { const count = useSelector ( state => state.count ) return ( < div > < Counter count = { count } / > < AnotherComponent / > < /div > ) } countはCounterコンポーネントには必要だが、 AnotherComponent には関係のない状態です。 良い例 const Hoge = () => { return ( < div > < Counter / > < AnotherComponent / > < /div > ) } const Counter = () => { const count = useSelector ( state => state.count ) // ... } ただ、一つ問題点があり、このCounterコンポーネントがpropsのcountのみを表示する共通コンポーネントの場合です。 そのような場合は以下のようにしています。 良い例2 const Hoge = () => { return ( < div > < HogeCounter / > < AnotherComponent / > < /div > ) } // Hoge専用のCounter const HogeCounter = () => { const count = useSelector ( state => state , count ) return < Counter count = { count } / > } interface CounterInterface { count: number } const Counter = ( { count } : CounterInterface ) => { return ( < span > { count } < /span > ) } まとめ 状態に関係のないコンポーネントが見つかった場合は状態が使われているコンポーネントを新たに切り出します Reduxを使っている場合はより意識します storeで状態を管理しているため、コンポーネント外から対象の状態(上記でいうと、state.count)に変更を加える可能性があるため さいごに この記事で説明したことを少しでも意識し始めたことによって自分はかなり再利用性の高いコンポーネントが実装できたと感じているので、参考にしていただければなと思います。 時にはこのケースに当てはまらない場合もあるとは思いのますが、その時は新たな観点で考えて貰えれば幅もより良い実装になっていくと思います。 株式会社スタメンでは一緒に働くエンジニアを募集しています。ご興味のある方はぜひ エンジニア採用サイト をご覧ください。
https://www.cypress.io 目次 はじめに Cypress cypress-on-rails おわりに 1. はじめに はじめまして、株式会社スタメンでエンジニアをしています伊藤です。普段はRuby on Railsを使っているサーバー側の人間なのですが、重要な機能を守るためにE2Eテストを書くことになりました。Railsで単体テストを書く際はFactoryBotでテストデータを作り、RSpecで単体テストを行うというお決まりパターンでコードを書いていましたが、今回は Cypress と cypress-on-rails 使いE2Eテストを書いてみたのでその内容について紹介できればと思います。 Cypressとはですが、簡単に言ってしまえばE2Eテストを行うことができるOSSです。Cypressは導入がとても楽なので、触り始めたばかりの頃は「なんて便利なものなんだ!」と、なるのですが、ネイティブのJavaScriptにはない独特の仕様が多く一筋縄ではいきません。代表的なものだと、Promiseやasync/awaitは基本的には使えないです。非同期処理をどうするかはCypressを触る上でとても重要なポイントです。公式にドキュメントがしっかりとまとめられており、また多くのエンジニアがissueを立てているので、そのあたりをちゃんと読めば大体の問題は解決できると思います。ただし英語です。 RailsエンジニアがCypressを触るのであればcypress-on-railsの利用を考えてみても良いと思います。cypress-on-railsの利点として、FactoryBot経由でテストデータを作成できることがあげられます。これまでtraitなどで積み上げてきたテストケースの財産を再利用できるので、これまで頑張って単体テストを書いてきた人ほどハッピーになれます。ただし、実際に触ってみると非同期処理や変数の扱いが分かっていないと分からない難しさがあるので、Cypressの仕様に触れてからcypress-on-railsの話に入っていきたいと思います。 2. Cypress Cypressではテストランナーとダッシュボードが提供されており、テストランナーはGitHubでソースコードが公開されているOSSで、ダッシュボードでは一部機能を無料で利用することができます。SeleniumなどのようにWebDriverを入れたりする必要がないので、環境構築に対するコストが小さいことも魅力的です。Dockerを利用する場合、テスト用のサーバーの実行とCypressが実行できるコンテナが用意できればいいため、CircleCIとの連携も比較的簡単です。 Cypressでは公式HPで多くのBest Practiceが示されており、基本的にはそれに則るコーディングが推奨されています。Cypress自体はnode環境下で実行されるJavaScriptになりますが、実行のされ方が特殊です。書くコードがそのままコード通り同期的に評価されるのではなく、キューに蓄えられてから非同期的に実行されます。どういうことかというと、Cypressが用意しているAPIとネイティブのJavaScriptの書き方を組み合わせると意図しないタイミングで評価されてしまい、思い通りの処理が実現できないということになります。つまり、Cypressのコードを書く際は Cypressのガイド で示されている書き方に従いコーディングを行うことになります。その中でいくつか特徴的な仕様について紹介します。 Cypressの仕様で複雑なものとして非同期処理に関する部分があげられます。最近のモダンなJavaScriptの書き方に慣れている人からすれば、非同期といえばasync/await、少なくともPromiseの使用をイメージすると思います。しかし、 公式 で述べられているようにCypressではES7のasync/awaitはサポートしていません。Promiseは存在しますが、ネイティブのPromiseとは異なり Cypress.Promise で生成されたオブジェクトのみ挙動を保証しています。(内部モジュールとしては Bluebird を使っているようです。) Why can’t I use async / await? If you’re a modern JS programmer you might hear “asynchronous” and think: why can’t I just use async/await instead of learning some proprietary API? Cypress’s APIs are built very differently from what you’re likely used to: but these design patterns are incredibly intentional. We’ll go into more detail later in this guide. 公式の設計デザインとしてasync/awaitは使用しないとされており、ガイドに則った非同期処理のコーディングが求められます。例えば、Cypress公式の見解として非同期処理を行う場合は then() や intercept() といったCommandと呼ばれるAPIの利用や、 Chains of Commands に従ったコーディング、 Custom Command の利用を推奨しています。 Test Structure Cypressは Mocha と Chai をベースにしています。そのため、MochaやChaiのTDD/BDDの記法にしたがってコードを書くことになります。テストコード全体の構成としてはMochaをベースにしています。そのため、 describe() や context() 、 it() 、 specify() などRailsエンジニアであれば馴染みのあるBDDスタイルでコーディングすることが基本となります。Webページ上のDOMを参照する際はjQueryのエンジンを利用しています。そのためセレクターの書き方は古き良きjQueryの書き方に従うことになります。( https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#Cypress-Can-Be-Simple-Sometimes ) describe( 'Post Resource' , () => { it( 'Creating a New Post' , () => { cy.visit( '/posts/new' ) // 1. cy.get( 'input.post-title' ) // 2. .type( 'My First Post' ) // 3. cy.get( 'input.post-body' ) // 4. .type( 'Hello, world!' ) // 5. cy.contains( 'Submit' ) // 6. .click() // 7. cy.url() // 8. .should( 'include' , '/posts/my-first-post' ) cy.get( 'h1' ) // 9. .should( 'contain' , 'My First Post' ) } ) } ) Cypressは実行の前後のhooksについてもMochaにおけるhooksの仕様が受け継がれています。( https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Hooks ) beforeEach(() => { // root-level hook // runs before every test } ) describe( 'Hooks' , () => { before(() => { // runs once before all tests in the block } ) beforeEach(() => { // runs before each test in the block } ) afterEach(() => { // runs after each test in the block } ) after(() => { // runs once after all tests in the block } ) } ) before でテストデータの準備などを行い、 beforeEach でログインやCookie周りなどテスト毎のステート管理を行うことが基本となります。素直に考えれば after や afterEach は before や beforeEach のステートを綺麗にする処理を書きたくなると思いますが、それは アンチパターン のようなので、正直使い所が難しいです。 Chains of Commands Chains of Commands とは、基本的にはJavaScriptのメソッドチェーンになります。Cypressでは実行が非同期的に行われます。そのためネイティブのJavaScriptではなくCypressで用意しているAPIを使い実行順序を制御します。 各コマンドはキューに一度蓄えるため、ネストが同じ高さのコマンドは同期的に実行されることになります。しかし、実行結果の内容を受けて処理を変化させたい場合や、DOM要素の属性値を参照したい場合などは各コマンドの実行結果を受け取りたいはずです。実行結果を他のコマンドに確実に渡す方法としてコマンドのチェーンがあります。 Cypressのコマンドは必ず返り値が存在します。前回実行したコマンドの結果のことをCypressでは subject と呼びます。subjectはDOM要素や数値、文字列、オブジェクトなど様々な型になりますが、 この設計 はChaiおよびChai-jQueryから組み込まれているそうです。コマンドをチェーンしていくことでこのsubjectが次のコマンドへと渡されていくため、アサーションを実行することができます。チェーンの途中で get() などを挟むことでsubjectを変えることもできるため、全てのコマンドをチェーンさせたコードを書くこともできます。ただし、コマンドによってはsubjectとして何も渡さないものも存在します。コードの可読性を考えて、一連のまとまったテスト内容はチェーンさせて、テストしたい内容が変わるタイミングでわざとチェーンを一度外すといった書き方もできます。 Commands Cypressの実行が非同期的です。そのためネイティブのJavaScriptではなくCypressで用意しているAPIを使い実行順序を制御します。その中で非同期処理に関連する get() 、 then() 、 as() 、 wait() 、 intercept() の4つを紹介します。これらのコマンドは実際に使う中で何度も悩まされたコマンドです。 get get() はおそらくCypressでコードを書く中で一番使用頻度が高いコマンドです。使用方法としては大きくわけて2つあり、DOM要素の取得とエイリアスの参照です。DOM要素を参照する方法はjQueryのセレクターの記法に準拠します。また、DOM要素が実行したタイミングですぐに見つからなかった場合でも、自動的にリトライ処理が走り、遅延してレンダリングされた場合でも取得することが可能です。 get() での検索自体がアサーションとして働くため。時間をおいても対象が見つからずタイムアウトした場合は、テスト項目の失敗となります。そのため、DOM要素の存在有無を繰り返し判定するような処理を行いたい場合には使えません。エイリアスについては as() の説明で述べます。 cy.get( 'button' ) // button要素 cy.get( 'div.test' ) // div要素でクラス名がtest cy.get( '[data-cy=hoge]' ) // data-cy属性値がhoge then then() は直前のコマンドの結果をうけてコールバック関数を実行するコマンドです。 then() を使用するタイミングとしては、大きく分けて2つの状況があげられます。1つ目は変数の扱う状況です。公式のガイドラインによると、Cypessコマンドの実行結果を変数に格納する方法はアンチパターンとされています。理由としては、呼び出すタイミングで対象となるオブジェクトが存在している保証がないからです。 then() を使って呼び出すことで、コールバックのスコープ内であれば確実にオブジェクトを参照することができます。 cy.wrap( 'hoge' ).then(text => { const result = text + 'fuga' cy.wrap(result).should( 'eq' , 'hogefuga' ) } ) 2つ目はDOM要素を参照する場合です。Cypressの場合DOM要素を参照する場合 get() を使います。参照したDOM要素を検証する場合には続けて should() などを使いますが、変数化して扱いたい場合は then() を使わなければなりません。 get() の返り値としてjQueryセレクターを返しますが、そのまま変数化しても変数化の処理の部分だけが同期的な処理になってしまうので、思い通りの挙動をしない恐れがあります。 const $elem = cy.get( 'button' ) // ここは同期的 $btn.click() // ここは非同期的、いつ実行されるか分からない DOM要素を確実に変数化したい場合は then() を使い、引数から参照するようにします。 cy.get( 'button' ).then($btn => { // $btnはjQueryオブジェクト const text = $btn.text() // Cypressのコマンドを実行できる形にするには一度wrapで変換する必要がある cy.wrap($btn).click() } ) as thenを使用せずにオブジェクトを別のコマンドに渡す方法として、 as コマンドの使用があげられます。 then() は非同期的な実行が行われるCypressの中で変数などを扱う際に欠かせないものですが、使用するたびにネストが下がるため、jQueryのコールバック地獄の時のような深いネストが生まれてしまうことがあります。そこで、 as コマンドを使用することで、ネストを回避することができます。 as() はsubjectに対してエイリアスをはるコマンドです。ここでいうエイリアスとは、直前に実行したコマンドのsubjectを参照するためのkeyとなる文字列のことで、チェーンしていなくても get() から値を参照することができます。 get() は対象が見つかるまで処理が繰り返されるので、評価が終わるまでは次のコマンドに移ることはありません。なので、直前に非同期的な処理の結果を as() で持つようにし、 get() を使い処理が完了するのを確実に待つことができます。 cy.get( 'button' ).as( 'btn' ) cy.get( '@btn' ).click() then() のネストを避けて変数化の代わりを行う方法として有用ですが、落とし穴があります。 as() ではられたエイリアスは get() で参照されるとライフサイクルを終えてしまうので、再度参照することができなくなります。なので、繰り返し参照する可能性がある結果に対しては不向きです。 wait 非同期的な処理を待つ方法として as() と get() を使用するパターンを述べましたが、通信処理を待つ場合には get() ではなく wait() を使う方が良いです。 get() はそれ自体にアサーションを含むためタイムアウトしてしまった場合はテストの失敗となってしまいます。しかし、通信結果がなかなか返ってこず、タイムアウトした後に再度処理を繰り返したい場合は wait() が適しています。 wait() の使い方は、引数に与えられた一定時間を待つという使い方と、エイリアスを待つという使い方の2つがあります。前者の使い方はsetTimeoutなどと同じように馴染みのある使い方ですが、テストの不安定さに繋がるためCypressではアンチパターンとされています。一定時間ではなく結果を待つ方法としてエイリアスを使う方法があり、こちらの使用が推奨されます。 intercept 非同期処理の定番として通信処理があげられます。例えば、APIを投げる処理が走った場合に、レスポンスが返ってくるまで処理を待ちたい状況が考えられます。 intercept() はクライアント側から送られるリクエストを監視できるコマンドです。以前は route() というコマンドが使われることが多かったようですが、Fetch APIへの対応など様々なネットワーク層の仕様に対応したコマンドになっています。 intercept() は単体で使うことはなく、基本的には as() と wait() とセットで使います。 cy.intercept( '/results' ).as( '@results' ) // リクエストの内容を記述、asでエイリアスを作る cy.get( 'button' ).click() // リクエストが飛ぶ処理 cy,wait( '@results' ) // エイリアスの内容が得られるまで待つ cy,get( 'li' ).should( 'have.length' . 10) // 結果をアサーション Custom Command Cypressの標準で実装されているコマンドについていくつか紹介しましたが、ユーザーがコマンドの組み合わせで独自で定義する Custom Commands と呼ばれるものがあります。 Custom Commandのベストプラクティス で内容について書かれていますが、ログイン処理や通信処理などよく使われ関数化したい処理をCustom Commandsにするのが良いとされています。JavaScriptのコードなのでもちろんネイティブの関数定義で複数のコマンドをまとめることもできますが、Custom Commandで定義された処理はチェーンすることで非同期的な処理内容でも確実に制御することができるので、なるべく関数ではなくCustom Commandで定義する方が良いです。特にPromiseが必要になるような処理を書きたい場合はCustom CommandsでCypress.Promissを返す必要があります。( https://qiita.com/murata0705/items/100ef8300caeeaa7d409 ) Cypress.Commands.add( 'hoge' , () => { // cyコマンドの処理 } ) Sharing Context Cypressで変数を扱う方法として then() と as() を紹介しましたが、複数のものを何度も参照したいケースでは使い辛いです。そこで、一部のケースにおいてこの問題を解決する方法として sharing context というものがあります。Mochaの仕様として、 before などのhookではられたエイリアスは this.* で参照することができます。これを用いることで、beforeで行った処理結果をitで参照することができます。また、複数のデータを渡す場合でもネストを下げることなく繰り返し参照することができます。ただし、 before から this のスコープが渡されることが必要となるため、arrow式でitにコールバックを渡した場合には利用することができません。shared contextを利用する場合は必ずfunction式で渡します。渡したいデータが少ない場合は then() や get() で参照し、渡すデータが多い場合はshared contextを利用するなどの使い分けができると思います。shared contextを利用する場合、function式とarrow式が同じファイル内で混在しがちになりますが、shared contextを使う場合だけコールバックをfunction式で書くと言ったルールにすれば、書き方から意図を伝えることができます。 before(() => { cy.fixture( 'users.json' ).as( 'users' ) // jsonファイルの読み込み結果に対してエイリアスをはる } ) // shared contextを使う場合はコールバックをfunction式にする it( 'utilize users in some way' , function () { const user = this .users [ 0 ] cy.get( 'header' ).should( 'contain' , user.name) } ) 3. cypress-on-rails CypressはWebブラウザでの挙動を自動的にテストしてくれるツールです。そのため、サーバーサイドで準備するテストデータはCypress外部で用意をしておく必要があります。そこで今回はRails環境下でCypressを使用する際に便利な cypress-on-rails というgemについて紹介します。 cypress-on-railsの最大の特徴は、CypressからのRubyファイルを実行できる点にあります。FactoryBotによるテストデータの作成やtest fixturesの利用が可能です。これによりこれまで培ってきた既存のテストデータの作成が再利用できます。FactoryBotを使う場合であればtraitやtransientを使い、簡潔にコードを記述することも可能です。 インストール gemをインストールするためにGemfileに次の記述を追加します。 group :test, :development do gem 'cypress-on-rails', '~> 1.0' end gemのインストールの次は、cypress-on-rails用のボイラープレートが用意されているのでそれも合わせて実行します。 bin/rails g cypress_on_rails:install 実行すると以下のようなディレクトリとファイルが生成されます。 config/environments/test.rb config/initializers/cypress_on_rails spec/cypress/integrations/ Cypressのテストファイルを格納する spec/cypress/support/on-rails.js cypress-on-railsに必要なCustom Commandsの定義 spec/cypress/app_commands/scenarios/ テストデータなどを作成するシナリオファイルを格納する spec/cypress/cypress_helper.rb コマンドが実行される前に評価されるファイル 自動的に追加されるものではないですが、FactoryBotの利用やデータベースのクリーンアップ、静的なテストデータの読み込み、Cypress外でのNodeプロセスの実行などを行う際には、加えて以下のディレクトリやファイルが必要になります。 spec/cypress/fixtures/ Cypress内で読み込むテストデータを格納する spec/cypress/plugins/ Cypress外のNode.jsのイベントを登録する spec/cypress/app_commands/clean.rb データベースのクリーンアップ spec/cypress/app_commands/factory_bot.rb FactoryBotの設定 cypress-on-railsではCypressのコマンドがフックでRubyファイルが実行されます。仕組みとしては、Cypressの cy.request コマンドを用いてサーバーへリクエストを送り、送られてきたリクエストの内容に従い実行するRubyファイルを見つけ実行し、実行結果をレスポンスとして返すことでファイルの実行と実行結果の取得を行います。app_commandsで定義したRubyファイルは Kernel.eval で評価されます。そのため、DRYなコードを実現するためにはFactoryBotのtraitを最大限に使用するなどの工夫が必要になります。 使い方 FactoryBotを使用したデータの作成は以下のような形で記述することができます。 bot = CypressOnRails :: SmartFactoryWrapper params = command_options.symbolize_keys user = bot.create( :user , name : params[ :name ], password : ' password ' ) article = bot.create( :article , :only_text , user : user, title : ' 素敵なタイトル ' , value : params[ :value ]) return { id : user.id, password : user.id, } Cypressのテストコードで実際に使用する場合は次のように cy.app もしくは cy.appScenario で実行することができます。 const data = { name: 'テスト太郎' , value: '素敵な文章' , } // cy.appを使ったパターン cy.app( 'scenarios/create_data' , data).then(res => { cy.login(res) } ) // cy.appScenarioを使ったパターン cy.appScenario( 'create_data' , data).then(res => { cy.login(res) } ) cypress-on-railsではrubyファイルの評価結果をsubjectとして渡すことができるので、shared contextと組み合わせればサーバー側から複数の情報を簡単に参照することができます。また、DBに対する操作も間接的に可能であるため、テスト用に追加でサーバー側にAPIを定義せずに様々なテストケースを再現することができます。追加の設定でDBのクリーンアップも行えるので、テストごとに独立したテストデータを用意することもできます。 cypress-on-rails はテストデータを用意する際にとても便利なのですが、ruby側の処理でエラーが合った場合、Cypress側ではエラーコードが500のレスポンスでタイムアウトしたことしか分かりません。実際のエラー内容を確認するには実行ログを見るしかありません。さらに、FactoryBotのtraitの定義に問題が合った場合はログにも現れないことがあります。デバックの面では使いにくさが残ります。 4. おわりに Rails環境下におけるE2EテストとしてCypressとcypress-on-railsを用いた方法について紹介しました。Cypressは導入が簡単でCIとのシナジーも高い部分がメリットとしてあげられますが、独特な仕様や非同期処理の扱いづらさがデメリットとしてあげられます。cypress-on-railsを使うことでCypressでもFactoryBotなどのRubyの財産を使ったテストデータの作成ができ、素早く様々なシナリオでのE2Eテストを作ることができます。重要な機能はこうしたE2Eテストなどでこれからもしっかりと守っていきます。 スタメンでは一緒に働くエンジニアを募集しています。興味がある方は、ぜひ 採用サイト からご連絡ください!