TECH PLAY

株式会社スタメン

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

231

本文 こんにちは、スタメンの松谷です。 最近、 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テストなどでこれからもしっかりと守っていきます。 スタメンでは一緒に働くエンジニアを募集しています。興味がある方は、ぜひ 採用サイト からご連絡ください!
アバター
はじめに こんにちは!スタメンでエンジニアインターンをしている松山です。約半年間インターンをしてきました。今回はインターンの振り返りを書いていこうと思います。 自己紹介 私は現在、愛知県の大学2年生です。大学では社会福祉を専攻していてその中でも特に社会福祉事業の最適化について研究しています。 スタメンには2020年の8月からインターンとして参画しました。業務では主にiOSアプリの開発を行っています。 スタメンでのインターン以外に実務経験はなく、文系の学部に通っていたので、参画時は正直右も左も分からないというような状態でした。しかし半年間のインターンを経てTUNAGの新機能開発・機能改善を行えるまで成長しました。 下記ではインターンとしてどのような業務をしていったのかを書いていこうと思います。 これまでの業務内容 最初の一ヶ月間 まず初めの1ヶ月は小さな不具合修正や細かいタスクをこなしながらTUNAGのコードを理解していきました。ちょうど自分がインターンを始めるときにコロナウイルスの影響でリモートワークが進み仕事のやりづらさがありました。しかし、開発チームのみなさんがDiscordの通話を常時繋げっぱなしにし、わからないところを気軽に聞ける環境を作ってくださったので、なんとかタスクを潰していくことができました。インターンでの初めてのタスクは、ボタンの文字を「完了」⇨「閉じる」に変更するものでした。簡単なタスクでしたが、初めてPRを提出するときは緊張しました。 2ヶ月目〜 それからしばらくして、TUNAGのコードにも慣れてきた頃少し複雑な機能の開発を任されました。タイムラインのコメント入力画面をリニューアルするというタスクでした。これまでの業務ではピンポイントで特定の箇所を修正すれば済むものでした。しかし今回は自分の書いたコードが今後どのように使われるのかなどを想定しなければいけなかったため、アーキテクチャの理解やオブジェクト指向の理解に苦しみました。しかし開発を進めていくうちに自分の中で段々苦しんでいたことが腑に落ちるようになってきたためここで大きく成長できたと思います。 現在 それからはOSのアップデートの対応やそれに付随して必要になったライブラリの導入などを行い現在は比較的大規模なプロジェクトに携わっています。インターンではありながらも、社員と同じ業務を任せてもらっています。 なぜ未経験の文系大学生が社員と同じような業務をできるまで成長できたのか 半年間のインターンを経て見違えるほど成長することができました。一番僕の成長を後押ししてくれたのはTUNAGが急成長中のプロダクトであるということだと思います。成長中のプロダクトは、課題が多くあり、その分プロジェクトも多く用意されます。そのどれもが重厚な開発経験を積めるものばかりであったため、自分の成長に大きく起因したと思います。 また、スタメンではセキュリティ勉強会や、コンピューターサイエンス勉強会など、初心者と上級者の知識の溝を埋めてくれるような勉強会が開催されていたので、それに積極的に参加していったのも自分の力を底上げする要因になったのではないかと思います。 おわりに スタメンは周りに優秀なエンジニアが多く非常に切磋琢磨できる環境です。さらに若手にも多くのチャンスが降ってきます。自分はこの半年間で多くを任せていただき成長することができました。今後も良いプロダクトを作る過程で自身が成長していけると思うとワクワクします。 最後に、株式会社スタメンでは一緒に働くエンジニアを募集しています。ご興味のある方はぜひ エンジニアサイト をご覧ください。
アバター
スタメンエンジニアの井本です。 普段の業務ではRuby on Railsを用いた機能開発を担当しています。 前職である電気回路の設計エンジニアからWebエンジニアに転身し、11月から働いています。 スタメンでは、エンジニアの技術力向上に力を入れており、社内勉強会を積極的に実施しています。 今回は、私が11月〜12月に参加した「みんなのコンピュータ・サイエンス勉強会」についての記事です。 当記事のトピックは大きく2つ。 1. 勉強会のレポート 2. 勉強会の中でRubyで実装したアルゴリズムについて 1においては、エンジニアとしてスタメンで働くことで、どのような環境でどう成長できるか?イメージする一助となれば幸いです。 2は、Rubyならこう書く、といった技術寄りのコンテンツです。 勉強会レポート テキスト みんなのコンピュータサイエンス(翔泳社) 内容 計算量 データ構造 アルゴリズム(ソート、探索) データベース(リレーショナル、非リレーショナル、分散データベース、データの一貫性) コンピュータ(アーキテクチャ、コンパイラ) 所感 大学時代の専攻や前職で、アルゴリズムやデータベースについては、学ぶ機会がありましたが、あくまで知識として持っているに過ぎませんでした。 今回の勉強会で何より良かったことは、先輩エンジニアに質問しながら理解を深めることができた点です。 このため、今回の勉強会を通して、Webエンジニアの立場でどう実現するのか、選択していくのか等、以前よりも実務をイメージしながら学ぶことができました。 業務に活かせていること 計算量を意識できるようになった 明らかに計算量が増えるような構造に注意が向くようになりました。 例えば、 each などリストを扱うメソッドをネストする、といったコードを、Webエンジニアデビューする前には気にせず書いていたものです…。 Rubyならではの書き方に関心が向くきっかけとなった 教材の書籍においては、例で掲載されているコードは、擬似コードを用いているため、経験の浅い私には少々イメージがしづらいところがありました。 そこでアルゴリズムの章では、自らRubyで実装しました。 実際に動かすことでアルゴリズムの動きを感覚的に理解ができただけでなく、コードレビューをもらうことで、Rubyらしいシンプルな実装を学ぶことができました。 Rubyで書くコードの明快さと、自身が書くコードの不明瞭さに気づくことができました。 この時に書いたコードに関するコードについては、次のトピックで実際に触れて参ります。 Rubyによる各種アルゴリズムの実装 すべて載せてしまうと、相当な文量となってしまうので、Rubyで実装することで違いが顕著だったコードのうち2つをピックアップします。 挿入ソート テキストのコード function insertion_sort(list) for i ← 2 ... list.length j ← i while j and list[j- 1 ] > list[j] list.swap_items(j, j- 1 ) j ← j - 1 Rubyで動作のみを再現したコード def swap (ary, x, y) ary[x], ary[y] = ary[y], ary[x] end def insert_sort (ary) for i in 1 ..(ary.length - 1 ) j = i while j > 0 && ary[j- 1 ] > ary[j] swap(ary, j- 1 , j) j = j - 1 end end end リファクタリング後 ary = # ランダム数列 module Sortable def swap! (i, j) self [i], self [j] = self [j], self [i] end def insert_sort 0 .upto self .size- 1 do | i | i.downto 1 do | j | break if self [j- 1 ] < self [j] swap!(j- 1 , j) end end end end ary.extend Sortable ary.insert_sort Rubyでは for 文を使わない、ということで upto メソッドで代替しました。 合わせて while 文についても down_to で置き換えています。 イテレータの制御をメソッド自身に任せる点でRubyらしいといえます。 他にはArrayオブジェクトにて extend して用いることで、関数ではなくメソッドとして swap や insert_sort を実施できるようにしました。 DFSとBFS DFSとBFSとは DFS(Depth First Search)=深さ優先探索、 BFS(Breadth First Search)=広さ優先探索 と呼ばれるアルゴリズムのことです。 グラフを探索するにあたって、どのような順序でノードを巡回していくか?を指すものと思っていただければ、差し支えございません。 具体的には次の2つの図のような順番で探索を進めます。 DFSの場合 数字の順番に探索が行われます。 ノード0から探索を開始する ノード1, 5, 6を発見する ノード1が条件に合致するか確認する ノード1に接続されたノードを探す ノード2を発見する ノード2が条件に合致するか確認する ノード2に接続されたノードを探す ノード3, 4を発見する ノード3が条件に合致するか確認する (以下、同様) このように、新しく発見したノードから先に探索を進めていく方式がBFS(広さ優先探索)です。 BFSの場合 ノード0から探索を開始する ノード1, 2, 3を発見する ノード1が条件に合致するか確認 ノード1に接続されたノードを探す ノード4を発見する ノード2, 3も1と同様に、条件を確認した上で接続ノードを見つける ノード1, 2, 3の探索を完了する 新しく発見したノード4, 5の条件を確認する (以下、同様) このように、先に発見したノードから先に探索を進めていく方式がBFS(広さ優先探索)です。 早速、コードを見ていきましょう。 テキストのコード function DFS(start_node, key) next_nodes <- Stack. new () seen_nodes <- Set. new () next_nodes.push(start_node) seen_nodes.add(start_node) while not next_nodes.empty node <- next_nodes.pop() if node.key = key return node for n in node.connected_nodes if not n in seen_nodes next_nodes.push(n) seen_nodes.add(n) return null function BFS next_nodes <- Queue. new () seen_nodes <- Set. new () next_nodes.enqueue(start_node) seen_nodes.add(start_node) while not next_nodes.empty node <- next_nodes.dequeue() if node.key = key: return node for n in node.connected_nodes if not n in seen_nodes next_nodes.enqueue(n) seen_nodes.add(n) return null Rubyによる実装 クラス実装 class Graph attr_accessor :nodes def initialize (nodes = []) @nodes = nodes end def initialize_search_memory @next_nodes = [] @seen_nodes = [] end def push_memory (node) @next_nodes .push(node) @seen_nodes .push(node) end alias :queue_memory :push_memory def pop_next_nodes @next_nodes .pop end def dequeue_next_nodes @next_nodes .shift end def saw? (node) @seen_nodes .include?(node) end def next_nodes_exist? @next_nodes .any? end def connect (key1, key2) if (v1 = find_node(key1)) && (v2 = find_node(key2)) v1.connect(key2) v2.connect(key1) else false end end def find_node (key) nodes.find{| v | v.key == key } end def get_connected_nodes (node) keys = node.connected_nodes nodes = keys.map{| k | find_node(k)} end end class Node attr_accessor :key , :value , :connected_nodes def initialize (key, value) @key = key @value = value @connected_nodes = [] # Nodeオブジェクトのkeyを格納する end def connect (key) connected_nodes.push(key) end end DFS class Graph def dfs (start_key, key) initialize_search_memory start_node = find_node start_key push_memory start_node while next_nodes_exist? node = pop_next_nodes return node if node.key == key get_connected_nodes(node).each do | n | push_memory n unless saw? n end end end end BFS class Gragh def bfs (start_key, key) initialize_search_memory start_node = find_node start_key queue_memory start_node while next_nodes_exist? node = dequeue_next_nodes return node if node.key == key get_connected_nodes(node).each do | n | queue_memory n unless saw? n end end end end 実行結果 @graph = ' グラフの生成 ' @gragh .dfs( 0 , 3 ) => #<Node:0x00007fd68e85a7e8 key: 3, value: 35, connected_nodes: [4, 5, 8, 9]> @gragh .dfs( 0 , 3 ) => #<Node:0x00007fd68e85a7e8 key: 3, value: 35, connected_nodes: [4, 5, 8, 9]> テキストのコードでは、 stack や set など一般的なデータ構造を用いて書かれていました。 Rubyによる実装ではクラス定義を用いることで、引数を最小限に抑えることができたため、シンプルなコードで書くことができました。 おわりに 前半では、勉強会がどのように進められていたか?勉強会で何を得て、業務に活かすことができているか?について述べました。 自己研鑽して食らいついていくことは前提ではありますが、スタメンには、それをサポートする環境があります。 後半では、やや技術的な内容としてテキストの疑似コードをRubyで実装したコードを紹介しました。 バリエーション豊かなイテーレーションメソッドや、クラス定義を用いることで、よりシンプルに書けるRubyの良さを再確認しました。 今回は以上です。 最後までご覧いただき誠にありがとうございました。 スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。 Webアプリケーションエンジニア募集ページ
アバター
目次 はじめに HTTPヘッダーとは Content-Typeの概要 検証内容 おわりに はじめに こんにちは、スタメンでエンジニアをしている手嶋です。普段はReact+TypeScriptでフロントエンドを開発したり、RailsでAPIを作成しています。クライアントサイドからサーバーサイドへリクエストするに当たり、HTTPヘッダーのContent-Typeを柔軟に変える事でリクエストの記述をシンプルに出来たので、今回紹介したいと思います。 HTTPヘッダーとは まずHTTPヘッダーについてですが、以下のように定義されています。 HTTPヘッダーは、要求または応答に関する追加のコンテキスト及びメタデータを渡すHTTP要求または応答のフィールド HTTPヘッダーは以下3つにカテゴライズされる リクエストヘッダー:フェッチするリソースまたはクライアント自体に関する詳細情報を含むヘッダー。 応答ヘッダー:場所やサーバー自体(名前、バージョンなど)など、応答に関する追加情報を含むヘッダー。 表現メタデータヘッダー:メッセージ本文のリソースに関するメタデータ(言語、長さ、メディアタイプなど) Content-Typeは リクエストボディのメディアタイプを指定 する役割を持つので、表現メタデータヘッダーに該当します。 Content-Typeの概要 上述の通り、Content-Typeはリクエスト時にメディアタイプを指定する役割を果たします。 メディアタイプは、MIMEタイプや要素タイプとも言われ、インターネット上で転送される コンテンツの形式を表現する識別子 を表します。 具体的な種類の例として以下が挙げられます。ファイルは「形式」と適宜読み換えてください。 MIMEタイプ 文書の種類 text/plain テキストファイル text/csv CSVファイル text/html HTMLファイル text/css CSSファイル text/javascript JavaScriptファイル application/json JSONファイル application/pdf PDFファイル image/jpeg JPEGファイル(.jpg, .jpeg) image/png PNGファイル image/gif GIFファイル image/svg+xml SVGファイル application/zip Zipファイル video/mpeg MPEGファイル(動画) 検証内容 今回検証した内容は以下です。 前提としてReactアプリケーションはPOST(PATCH)パラメータ(params)をオブジェクトとして管理しています。そしてAPIにリクエストするファイルでは以下のようにパラメータを展開します。 パラメータは全てが必須項目ではなく、存在する場合のみリクエストに含める想定です。 改善前 // type type UserParamsType = { name: string email: string address: string phone: number gender: string } // user更新用の関数。 別関数にエンドポイントurlとリクエストbodyを渡す export const requestUpdateUser = async ( userId: number , params: UserParamsType ) => { // エンドポイント const url = `api/v1/user/${userId}` // パラメータ生成 const { name , email , address , phone , gender , } = params let body = '' if ( name ) body += `&[name]=${encodeURIComponent(name)}` if ( email ) body += `&[email]=${encodeURIComponent(email)}` if ( address ) body += `&[address]=${encodeURIComponent(address)}` if ( phone ) body += `&[phone]=${encodeURIComponent(phone)}` if ( gender ) body += `&[gender]=${encodeURIComponent(gender)}` const response = await fetchPatchTemplate ( url , body ) return response } // HEADERS // Content-Typeにはapplication/x-www-form-urlencoded; charset=utf-8を指定 const HEADERS = { Accept: 'application/json' , 'Content-Type' : 'application/x-www-form-urlencoded; charset=utf-8' , } // apiを叩く関数 export const fetchPatchTemplate = async ( url: string , body: string ) => { try { const response = await fetch ( url , { credentials: 'same-origin' , method: 'PATCH' , headers: HEADERS , body , } ) if ( !response.ok ) { throw Error ( response. statusText ) } const resJson = await response.json () return { payload: resJson } } catch ( error ) { return { error: 'エラーメッセージ' } } } 上記の通りContent-Typeには application/x-www-form-urlencoded; charset=utf-8 を指定しています。 このTypeは、 「キーと値が '=' を挟んで組になり、 '&' で区切られてエンコードされる」 という特徴を持ちます。 よってこのTypeを指定した場合は、上記の記述でparamsを生成し、リクエストbodyに含める事ができます。 改善案 しかし、上記の記述ではparamsの数だけ展開の記述をする回数が増えてしまいます。 その場合は、以下のように params展開部分 と ContentType を書き換える事で記述量を減らす事が可能です。 params展開部分 => JSON.stringify(params) JSON.stringify() メソッドは、あるJavaScript のオブジェクトや値をJSON文字列に変換するメソッドです。 ContentType => 'application/json' application/jsonに変更しJSON形式を扱えるよう変更します。 // type type UserParamsType = { name: string email: string address: string phone: number gender: string } // user更新用の関数。 別関数にエンドポイントurlとリクエストbodyを渡す export const requestUpdateUser = async ( userId: number , params: UserParamsType ) => { // エンドポイント const url = `api/v1/user/${userId}` // オブジェクト形式のparamsをJSON.stringifyの引数に渡しパラメータ生成 const body = JSON.stringify ( params ) const response = await fetchPatchTemplate ( url , body ) return response } // HEADERS // Content-Typeにはapplication/jsonを指定 const HEADERS = { Accept: 'application/json' , 'Content-Type' : 'application/json' } // apiを叩く関数 export const fetchPatchTemplate = async ( url: string , body: string ) => { try { const response = await fetch ( url , { credentials: 'same-origin' , method: 'PATCH' , headers: HEADERS , body , } ) if ( !response.ok ) { throw Error ( response. statusText ) } const resJson = await response.json () return { payload: resJson } } catch ( error ) { return { error: 'エラーメッセージ' } } } おわりに 今回はHTTP通信におけるヘッダー及びContent-Typeについて紹介させていただきました。 paramsの量にもよりますが、クライアントの記述を大幅に減らすことができる場合もあるので、これからもContent-Typeを柔軟に扱っていければと思います。 今回の内容が少しでも参考となれば幸いです。 スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。 Webアプリケーションエンジニア募集ページ 参考 HTTPヘッダー Content-Type MIME タイプ
アバター