レバレジーズ 株式会社のブログ - TECH PLAY

TECH PLAY

レバレジーズ 株式会社

レバレジーズ 株式会社 の技術ブログ

142

はじめに こんにちは。レバレジーズ株式会社の松本です。普段はHRテック事業部で主にフロントエンドの開発を担当しております。 今回の記事では、入社2ヶ月の私がテックフェス全体のレポートと、弊社フロントエンジニアの小林がReact18について発表した「Parce que j’aime la React.」を受けて、追加で調べたり試したりしたことをご紹介したいと思います! テックフェスとは? レバレジーズグループに所属するエンジニアを対象に社内で半年に一度行われている技術の祭典です。エンジニアが新しい技術に興味を持ち、勉強をするきっかけを作ることを目的とし、グループ全体の技術力向上を目指します。 11月22日行われたテックフェス2022秋では、「まずは知ろう。次に学ぼう。そして明日使おう」というテーマで開催されました。 テックフェス全体について 当日の流れは、有名な「テスト駆動開発」の日本語訳をされた和田卓人氏をお招きし、「質とスピード - ソフトウェア開発の典型的な誤解を解く(2022年秋版)」の基調講演から始まり、Sessionが2時間弱、LTが30分程、最後にパネルディスカッションが1時間程というタイムスケジュールでした。 オープニング ↓ 基調講演(和田卓人氏) ↓ Sessions(30分 × 4名の弊社エンジニア) ↓ LT(5分 × 5名の弊社エンジニア) ↓ パネルディスカッション(1時間 4名の弊社ベテランエンジニア) パネルディスカッションでは、「ITエンジニア35歳定年説について語る」というテーマで、意見を交わしていました。 ・面白かった発表 特に面白かった発表は、個人的には和田卓人氏の発表と最後のパネルディスカッションでした。 和田卓人氏の「質とスピード」についての発表では、スピードのために犠牲にしようとしている品質とは何を指すのか、品質とスピードはトレードオフではないことを経験談やデータに基づいてお話ししていただきました。 スピードおよび質とトレードオフなのは教育、成長、多様性への投資であるというお話もあったので、内部品質(スピードは落とさない)への投資はエンジニアだけでなく事業会社全体にとって重要であると感じました。 また、最後のパネルディスカッションも個人的には非常に面白かったです。 一般的に、「ITエンジニア35歳定年説」というのはネガティブな意味で広まっていると思います。 調べてみると一般的に35歳定年説と言われているのは、学習能力の低下や体力の低下が理由とされているようです。 ただ、今回はそれとは反対にポジティブな印象を受けました。 登壇された方々もキャリアを積むにつれてコードを書くことが減ってきているとのことでしたが、前述したネガティブな理由ではなく、できることが増えたことで業務の幅が広がったことが大きな理由の一つとなっているようでした。 エンジニアに関わらず、年齢に関するトピックではネガティブに語られることが多いイメージだったので、今回はポジティブな話を聞くことができて非常に良かったなと感じました。 ・関心があった発表 SessionsやLTの発表も非常に面白かったです。また、個人的には弊社フロントエンジニアの小林によるReactについての発表(30分のSesssion) 「Parce que j’aime la React.」 は、普段触れている技術領域ということもあり非常に関心がありました。 今回は、この発表から何を知って、何を学んで、何を使ったのかについても共有できればなと思います! 何を知って、何を学んで、何を使ったのか? ・知ったこと 小林の発表ではReact18で提供されるものとして、並行処理レンダリング、Suspense(正しくはアップデート)の紹介がありました。 例えば、以下のような実装だと、FetchComponent内でPromiseがthrowされている場合(サスペンドした場合)は、fallbackで指定されているComponentが返されます。このPromiseが解決された時はFetchComponentが返されます。 function App() { return ( <Suspense fallback= { <Spinner /> } > <FetchComponent /> </Suspense> ); } また、Reactの日本語公式ドキュメントには、以下のような記述があります。 サスペンスにより、「UI ロード中状態」というものが、React のプログラミングモデルで宣言的に記述可能な主要コンセプトに昇格します。 今まではuseQuery等を利用してloadingの状態を実装者が管理し、それを用いてUIを分岐させるような実装をしていた方も多いと思います。私も同じような実装をしておりました。 この分岐のロジックをReact側が担ってくれるようになるため、我々はloadingの状態やそれを利用した分岐に関心を持つ必要がなくなり、ロード中とロード完了後のUIを宣言するだけで良くなったということだと解釈しています。 ・学んだこと React18で提供される機能の一つとしてTransitionというものがあります。今回はこの機能について学んでみました。 Transition は更新処理に優先順位をつけたり、中断するための機能です。この機能では、更新処理を「緊急性の高い更新」と「トランジションによる更新(緊急性の低い更新)」で区別しています。緊急性の高い更新は即座にUIを更新します。一方、トランジションによる更新では緊急性の高い更新が発生したら更新処理を中断し、最終的な結果のみを返します。これはSuspenseと合わせて利用することもできるようです。 また、React18について学んでみて面白かったのは、この更新処理に優先度をつけたり更新処理を中断するような機能は ”React Fiber” というアーキテクチャによって実現可能になったということです。 Fiberについて調べていた中で、 こちらの記事 がとても面白かったです。 ”React Fiber”はReact16から採用されたもので、React16以前とは仮想DOMを更新してUIに反映するアルゴリズムが変更されているとのことです。今までは仮想DOMを上から再帰的に更新していたものが、各ReactElement一つ一つに対して更新処理が用意されるため、非同期的に仮想DOMを更新することが可能になったとのことみたいです。 また、少し古いものですが、 こちら にも以下のような記載があります。 The goal of React Fiber is to increase its suitability for areas like animation, layout, and gestures. “suitability”という単語が使われているように、FiberはUIの動きを絶対的に早くするものではなく適性を高めること、つまりUXの向上を目的にしていると考えられそうです。 この目的を達成するための基盤にFiberが必要で、今回のReact18で導入された機能はまさにこの目的を達成するための具体的な方法の一つと言えそうだなと感じました。 ・使ったこと 今回学んだ内容は業務ですぐに使える内容ではないこともあり、ここではサンプルを触ってみた感想のみにしようと思います。公式の React Docs に載っているuseTransitionのサンプルコードを手元で動かしてみました。 所感としてはSuspenseは比較的理解しやすく感じた一方で、useTransitionは思っていたよりも腰を据えて学んでいく必要があるものだなと感じました。 SuspenseもuseTransitionも背景には並行処理レンダリングがあって、私たちが見えている画面の状態とは別でもう一つ状態をもっていることが少し理解できました。 今後遠くない未来で実務での利用もありそうなので、引き続きインプットを続けていこうと思います。 最後に 今回の記事では、テックフェスで知った内容からReact18について自分なりに学んだことを書かせていただきました。ReactはUX改善のためのアップデートを続けているというのが非常に面白かったです。また、テックフェスは非常に魅力的な発表ばかりで、話を聞いていてとても楽しかったです。 私は今年の9月に中途で入社したばかりですが、このように組織内での技術の共有があるのは、ナレッジ共有の観点からも非常に有意義だと感じています。 皆さんもレバレジーズで一緒に働いてみませんか? レバレジーズに少しでも興味を持っていただけた方は、こちらからエントリーをお願いします! recruit.jobcan.jp
はじめに こんにちは、テクノロジー戦略室の新城です。私は2022年6月に中途入社し、入社してまだ半年ですがMLOpsの推進という仕事を担当しています。 現在レバレジーズでは レバテックダイレクト の求人マッチ度判定のような、レコメンドシステムを多くのサービスで活用しています。 一方で機械学習モデルを本番運用したはいいが、データサイエンティストがモデルの改善フローを回しにくく、再トレーニングしたモデルのデプロイが頻繁にできないといった問題が顕在化してきました。 そこで、今回は社内営業支援ツールにおいてMLOpsを導入し実際にデータサイエンティストがモデルの改善プロセスを回せるようになったその事例を紹介できればと思います。 背景 レバレジーズのMLシステムは MLOps レベル0 と言われる状態に近く、データサイエンティストがモデルを作成し、プロダクトへモデルを一度組み込むとその後の改善は行いにくく、プロダクトでのモデル品質も充分に確認できない状態でした。 あるプロダクトでは、図中のModel Servingの部分でプロダクションエンジニアがプロダクトの要求レベルに沿うように前処理の高速化等を行っており、トレーニング時と推論時に前処理が異なっている状態( Training-Serving Skew が起こっている状態)でモデルの更新に双方のエンジニアに工数がかかってしまう状況でした。 このような状況では、データサイエンティストは他のエンジニアの手を借りないとデプロイが出来ないという制約から、軽微なモデルの改善を手軽に行うことができません。加えて、レコメンドにおけるMLモデルはその品質を保つために、定期的に再学習を行い、トレンドを学習する必要があります。しかし、再学習時のデプロイ毎に双方のエンジニアに工数がかかる状態は好ましくありません。 そこで今回はデータサイエンティストが主導してモデルの改善・デプロイを行うことができる環境を整えることで、プロダクトエンジニアの工数を削減し、モデルの改善に積極的に取り組めるような仕組みづくりを目指しました。 出典: https://cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning?hl=ja#mlops_level_0_manual_process MLプラットフォームの導入 上記の状態を改善するため、今回AWS SageMakerやGCP Vertex AIに代表されるようなMLプラットフォームの導入を検討しSageMakerを採用することになりました。データの分析から学習、モデルデプロイを一手に担ってくれるプラットフォームを導入することでデータサイエンティスト主導の機械学習モデル開発・デプロイができるようになることが狙いです。 またMLパイプラインを用途ごとに設けることで MLOps レベル1 の状態に少しでも近づけることを目標としています。 構成図 今回はAWS SageMakerとStep Functions DataScience SDKを用いてMLパイプラインを構築しました。グレーアウトしている部分は今後の実装を予定している部分です。 図中の棒人間がいる箇所をデータサイエンティストに実装してもらい、後はパイプラインを実行していけばプロダクトに使用する図中左下のエンドポイントまでデプロイが完了します。 全体構成だけではわかりにくいと思うので各パイプラインの役割をそれぞれ見ていければと思います。 1. データ取り込みパイプライン まずはデータ取り込み処理部分について順を追って説明をしていきます。 1.1. Event Bridgeの起動 学習・推論に使用するデータをBigQueryから定期的に読み込むためにEvent BrigeでStep Functionsの起動をしています。 1.2. データ取得と前処理 レバレジーズではデータウェアハウスとしてBigQueryを採用しており、データサイエンティストはここから任意のデータを取得・前処理・データフレーム化して使用します。 少しややこしいのですが、ここでの前処理と後述する学習パイプラインの前処理は異なっており、統計的な特徴量(平均値や分散値など)等の推論毎に作成する必要のない特徴量を予め作成し、推論時にこれらのDataFrameを予め読み込んでおくことで推論処理の高速化を図っています。 1.3. データの品質チェック(将来実装) 取得するデータの特徴量ドリフト検知といった品質チェックや定期実行毎の特徴量の推移等を出力できるとデータサイエンティストに気づきを与えることができるのではないかと検討中です。 2. 学習パイプライン S3に用意されたデータを元に学習から評価までを行っています。Lambda Stepではモデルとその評価値をSageMaker Model Registryへ登録しています。 ここでの処理は一般的に紹介されている学習パイプラインとほぼ同等と思って頂けたらと思います。 3. デプロイパイプライン ModelRegistryへ登録されたモデルをデプロイするパイプラインです。 3.1. Model情報取得(Lambda) Step Functionsへの入力となるモデルパッケージ情報とバージョン情報を元にModel Registryから必要な情報を取り出しています 3.2. Endpoint新規作成・更新判定 このLambdaは単にEndpointの新規作成か更新かを判定するものです。入力されたEndpointNameを元に判定しています。 3.3. 推論Endpointの立ち上げ Endpoint立ち上げ時に前述のデータ取り込みパイプラインで作成したDataFrameを読み込んでおり、推論時に都度作成する必要のない特徴量はこちらを活用しています。 またデータキャプチャ設定で保存された推論結果をS3からBigQueryへ日次で転送しておりデータサイエンティストが推論結果をBigQuery上で解析できるようにしています。 3.4. エンドポイントに対しての自動テスト(未実装) データサイエンティストがプロダクトに関わるエンドポイントの実装とデプロイを行うということで、心理的安全性の担保ためにここの実装は必要だと考えています。 改善できたこと データサイエンティスト主導のモデル改善フローの実行 データサイエンティストが用意したパイプライン実行用のPython Notebookを使用して、モデルの改善を任意のタイミングでより容易に行うことができました。実際にSageMakerを導入してから短期間で複数回モデル変更のデプロイをデータサイエンティスト主導で行っておりその効果はあったのかなと思います。 一方で実行が手動なのは変わりなく、どのファイルを変更したらどのパイプラインを実行すれば良いといったフローはかなり煩雑なので、現在は変更に対応するパイプラインが自動実行されるといった自動化に着手中です。 Training-Serving Skewの改善 こちらの ルール の通り、コードを再利用する実装を事前にデータサイエンティストと進めたことで、基本的にはデータサイエンティストの変更のみでモデルのデプロイを行う仕組みを整えることができました。一方でエンドポイントのレスポンス速度は常に保証する必要があるため、前述した自動テストでのチェックや後述の前処理高速化に取り組んでいきたいと考えています。 試したが今回は盛り込めなかったもの 前処理の高速化(ApachBeam等の導入) 今回はリアルタイム推論だったのですが、比較的前処理の量も少なく、特に工夫せずともプロダクト要件を満たす処理時間に収めることができたため学習コストの観点から導入はしませんでした。 但し、今後時間のかかる前処理やストリーミングで捌かないといけないデータが必要となった際にはApachBeamといったバッチとストリーム処理が可能なデータ処理パイプラインを導入しなければいけないと考えています。導入の際には推論時のみだけでなく、学習側もきちんと共通化できるように考えて実装していきたいです。 Feature Storeの導入 S3へ特徴量データを保存している部分をFeature Storeに置き換えたかったのですが、定期実行時にFeature Storeへ書き込むと今回のデータ量では書き込み量が多く、想定以上の料金がかかってしまうことがわかりました。データの差分のみを登録する仕組みを作る案も出ましたが、定期実行ごとにDataFrameを作りバージョン管理できた方が便利なため今回は採用を見送っています。上記の前処理高速化と関連してストリーミングデータを扱う際にはまた必要だと考えています。 今後の施策 MLOps レベル1 への移行やより良いモデル開発体験を目指して以下のことを行っていきたいです。 パイプラインや運用プロセスの自動化 データサイエンティストがGitHub上で改修ブランチを切ったら前処理・学習・評価(ABテスト)まで自動で行われる仕組みを目指したい モデル評価機構の確立 特徴量や特徴量寄与度に違いがないか監視(ドリフト検知) モデルレジストリへ登録しているモデルへのリアルタイム評価の反映 もしこのモデルがデプロイされたらこのくらいのパフォーマンスを出すだろうといった予測を与えられたら 前処理高速化やFeature Storeの導入 今回は必要性がなかったため断念したが、今後に向けて実装を検討していきたい 最後に 今回の記事ではSageMakerを導入しデータサイエンティストがモデルの改善プロセスを回せるようになったその事例を紹介しました。MLOpsの導入と言い切るにはまだまだ改善すべきことが多いですが、まずは初めの一歩を踏み出すことができたのかなと思います。 また、これらの実装には入社してまだ半年の私を支えてくれた、データ戦略室のデータサイエンティストの理解と協力無くしては実現できませんでした。 このように、レバレジーズには年次や経験を問わず、主体的に取り組める環境が整っています。ご興味のある方は、以下のリンクから是非ご応募ください! recruit.jobcan.jp
はじめに  レバレジーズ株式会社 テクノロジー戦略室室長の竹下です。レバレジーズでは現在SRE(Site Reliability Engineering)チームを立ち上げ、全社を巻き込んでSRE化を推進しています。  SREを提唱したGoogleを筆頭に、日本でもメルカリやサイバーエージェントなど数多くのテック企業がSREチームを立ち上げ、インフラ基盤を維持管理するだけではなく、サイトの信頼性を担保することでサービス価値を最大化しようとしています。レバレジーズでも、1年ほど前からSREチームを立ち上げ、インフラからもお客様に最高のサービスを届ける事を目指しています。  この記事ではSREチーム立ち上げの背景と、レバレジーズがどのように顧客に寄り添ったSREチームを目指しているか、そしてSREで使用している技術のご紹介をしていきたいと思います。 背景  レバレジーズ株式会社では、チームをサービス単位で分けており、それぞれが大きな裁量を持ってサービス運営をしています。 これまでは、チーム毎にインフラ担当者を置きサービスに合わせた構築、運用を行ってきていました。そのため、チームによって使用技術やインフラ構成が異なっており、チームの統廃合や異動、退職などによりインフラ担当が変わる際には、高い学習コストがかかっていました。また、担当者に寄ってインフラのレベルもまちまちなので、CI/CDやIaCを組み込んで自動化出来ているチームもあれば、手作業でインフラ変更をしているためインフラ作業で手一杯になっているチームもあったりしました。このままでは、今後事業分野も拡大してより多くのチームが出来た時に、チーム間の流動性と、全体の効率が低下してしまい組織規模の拡大のブレーキとなることが予期され、また、レバレジーズではマイクロサービス化を進めておりその観点からもインフラ周りの効率化が必要となってきていました。  そこで、インフラ関連の知識を集約し標準化を図るとともに全体最適を行うため、プロジェクト横断チームとして、SREチームを立ち上げることになりました。 SREチームのミッション  SREチームのミッションとして SRE化を通して、Developer Experienceの改善、事業の拡大への対応、お客様に信頼されるサービスの提供を実現する を掲げています。  ただのインフラ屋としてタスクをこなすだけでは、レバレジーズのミッションである「関係者全員の幸福を追求する」ことの達成は難しく、また、インフラは顧客から少し離れた所で開発を行うため、誰のためのサービスなのかの意識が薄くなりがちです。そのため弊社のSREチームでは、インフラの上で動くサービスの更にその先に顧客がいることを忘れず、常にチームとしても個人としても成長を続けられることを目指し上記のミッションを設定しています。 目指すSRE組織  上記のミッションを達成するにあたってどのようなSREチームを構築すればよいか調べましたが、いくつかの型が紹介されていたり、他社事例を調べても各社それぞれSREチームの形は違っていました。そのため、レバレジーズの文化にもっとも適したSREの形を模索し、Embedded SREとEvangelist SREのハイブリッドを目指すことにしました。 Embedded SREとは  レバレジーズのエンジニアの使命は、世の中に価値あるサービスを届けるところにあると考えています。最大限の価値を届けるためには、Embedded SREは、ドメイン知識を持ち事業を理解した人材でないといけません。そのため、Embedded SREは開発チーム内のアプリエンジニアから選出してもらい、サービスのインフラ運営、監視、トイル撲滅、SLOの策定など、SREの実務を行いDevOpsの実現をしてもらいたいと考えています。 Evangelist SREとは  しかし、Embedded SREだけではチーム内に知識が閉じてこれまでと同様に局所最適に陥ってしまう可能性もあります。また、アプリエンジニアに任せることになるため技術レベルもチーム毎に違って来てしまいます。そこを補うためにEvengelist SREを置き、SREプラットフォームの構築、SREのトレーニング、ノウハウやベストプラクティスを各チームから吸い上げ、全社に浸透させるなどの動きをしていきます。今回立ち上げたSREチームは、このEvangelist SREの集団になります。 使用技術  SREチームで標準となる技術を策定し全社に広めることで、ノウハウやベストプラクティスの共有を効率的に行おうとしています。現在使っている or これから使おうと考えている主要な技術を紹介したいと思います。 Infrastructure as a Code (IaC) CDK Terraform Ansible  AWSでの新規のインフラ構築には主にCDKを利用しています。既存のリソースのインポートだったり、DataDogやGCPの場合はTerraformを利用しています。新しく立ち上げるサービスやリプレース時には、CDK,Terraformだけで構築できるようにしていますが、既存のEC2などでセットアップが必要な場合にはAnsibleを利用しています。 監視 Datadog Datadog RUM(Real User Monitoring) Datadog APM(Application Parformance Monitoring)  メトリックスやログは現在Datadogへ集約を進めています。また、DatadogもダッシュボードをIaC化し、全てのサービスで標準的な監視をすぐに始められるようにしています。またDatadog RUMやAPMを導入により、より詳細なメトリックスの取得し、効率的なパフォーマンスチューニングを目指しています。 サーバレス AWS Fargate Cloud Run Docker kubernetes  新しく立ち上げるサービスは全てサーバーレス化を進めています。SREチームの前身になるインフラチームの際には人数が少なくkubernetesクラスターの維持管理が難しと考えて、フルマネージドサービスである、AWS Fargateを採用しています。GCPではCloud Runを主に採用しています。ただ、SREチームの人数も増え管理を、より自由な構成を行うためにkubernetesの導入検討も続けてはいます。 マイクロサービス gRPC GraphQL AWS App Mesh  サーバレス化とともにマイクロサービス化も進めています。サーバー間の通信はgRPCを主に採用しており、一部のサービスではGraphQLも利用しています。また、サービス数も増えてきたため現在はAWSのフルマネージドのService MeshであるAWS App Meshの導入も進めています。 最後に  いかがでしたでしょうか?SREチームもシステムのその先にいるユーザーを視野に入れ、より良いユーザー体験を届けるために日々奮闘しています。また、ただ闇雲に頑張るのではなく、適切な技術を選定しかつ組織に浸透させるところまでを考えながらSRE化を進めています。  一緒にSREのチームひいてはSREの文化を創っていきたいという方や、今はまだインフラ知識が無いけどフルスタックエンジニアになりたいという意欲のある仲間を募集中です。ご興味のある方は、以下のリンクからご応募ください。  一緒にSREのチームひいてはSREの文化を創っていきたいという方や、今はまだインフラ知識が無いけどフルスタックエンジニアになって行きたいという意欲のある仲間を募集中です。ご興味のある方は、以下のリンクからご応募ください。 https://recruit.jobcan.jp/leverages/ おまけ  カバー画像にもしているのですが、AIに「Site Reliability Engineering」で描いてもらったらなんとなくそれっぽいのになりました
はじめに 初めまして、レバレジーズ株式会社の小林といいます。 私は2022年4月に開発未経験でエンジニアとして中途入社し、teratailというサービスのフロントエンド開発とマーケティング周りの業務に携わっています。 teratailでは、昨年末にリプレイスを行ったこともあり分析基盤がきちんと整備されておらず、各種分析ツール(Google Analytics4やBigQuery、Google Optimizeなど)を導入する必要がありました。 中でも今回は、導入に特に苦労した「Next.jsのServer Side RenderingでABテストを実装した方法」について紹介したいと思います。 使用技術 今回の実装で使用する主な技術は以下の通りです。 Next.js TypeScript Recoil, React Context (状態管理) Google Optimize (ABテスト) 背景 ■なぜこの記事を書いたのか 参考文献が少なかった 通常のReactアプリ(SPA)のOptimize導入については参考文献が豊富ですが、Next.jsのServer Side Rendering時などにサーバー側からABテストを制御する方法に関しては文献が多くありませんでした。公式リファレンスを読んでも「???」な部分が多くあり、予想以上に詰まったため、同じような苦労をされている方は多いのではないかと思い記事にしました。 今回の苦労を残しておきたかった このタスクを担当したときは入社3〜4ヶ月目の頃でしたが、開発経験ほぼゼロで入社した私にとっては難易度が高かったです。入社早々に今回のような貴重な経験ができたので、この苦労をここぞとばかりに共有したく、記事にしました。 ■なぜ分析基盤を整備するのか プロダクト自体は月間PV数が1,000万を超えており十分なデータが存在するにも関わらず、分析基盤が整っていないために、データドリブンに施策の立案や効果の検証が行えていないという課題がありました。 今後teratailが更にグロースしていく上で、データドリブンに意思決定を行える体制を作ることが必須だと考え、そのための分析基盤構築の一環としてABテスト環境を整えることとなりました。 ■対象の読者 特に以下のような方々の参考となれば嬉しいです。 Next.js(SSR)でABテストを実装したいエンジニア フロントエンドエンジニア ABテスト担当者、マーケター等 前提知識 ■Next.jsについて Next.jsとは Reactをベースにしたフレームワークで、Server Side Rendering(SSR)や静的サイトの生成(SSG)といった機能が追加されています。 Server Side Rendering(SSR)とは 出展: https://nextjs.org/learn/basics/data-fetching/two-forms プリレンダリング(ページにアクセスがあったときにサーバー側でJavaScriptを実行してHTMLを生成する)機能の一つで、SEO対策や高速なコンテンツ表示が可能となります。 通常のReactアプリ(SPA)との違い 通常のReactアプリ(Single Page Application)はブラウザ側でHTMLを生成するのに対し、SSRではサーバー側でHTMLを生成します。後述しますが、サーバー側で生成されるという点が、本記事の大きなポイントとなります。 ■Google Optimizeについて Google Optimizeとは Googleが提供する無償のABテストツールです。専用のタグをサイトに埋め込むだけで簡単にエクスペリエンス(=ABテスト)を実施することできます。エクスペリエンスやデータの集計は_gaexpというcookieで管理されます。 管理画面でエクスペリエンスの実施が可能 Google Optimizeの特徴として、ブラウザの管理画面で各種設定を行うことで簡単にABテストを実施することができる、という点が挙げられます。ノーコードでテストパターンの要素変更ができるため、非エンジニアでも気軽に操作することが可能です。 今回の問題点 通常、Google OptimizeのABテストはクライアント側で処理される仕様のため、サーバー側で機能を切り替えることができません。クライアント側でページの読み込み後に処理されるため、ABテストの実行(要素の変更)時にはどうしてもちらつき(ページフリック)が発生してしまいます。 今回、このちらつきを避けることや、管理画面側では設定できないようなABテストをプログラム側で実装することを考慮し、通常の方法ではなくサーバー側でOptimizeを使用する環境を整える必要がありました。 それではここから、本題の「Server Side RenderingとGoogle OptimizeでABテストを実装する方法」について説明していきます。 Server Side Rendering×OptimizeでABテストを実装する方法 ■結論 Server Side RenderingでGoogleOptimizeのABテストを実装するためには、Optimizeの サーバーサイドテスト の機能を使い、プログラム側とOptimize管理画面側でそれぞれ以下の項目を実装・設定する必要があります。 【プログラム側での実装】 1) テストパターンの割り当て 2) Optimize(GA)側にテストパターンを送信 3) テストパターンのグローバル管理、取得 4) コンテンツの出しわけ 【Optimize管理画面側での設定】 1)テストパターンの作成 2)ページターゲティング 3)その他 それぞれ説明していきます。 ■プログラム側での実装 1) テストパターンの割り当て まず、ABテストのseed値がCookieにあればCookieから取得、なければ生成する関数を定義します。 (※seed値にはuserIdを用いてもOKですが、今後「ログアウト⇨ログイン状態」をまたいだABテストを行うことも考慮し、userIdを利用せずにABテスト用にseed値を生成してパターンの振り分けを行うように実装しています。) 【abTestSeed.ts】 import { NextPageContext } from 'next' ; import nookies , { parseCookies } from 'nookies' ; export interface AbTestSeed { seed: string ; } export const COOKIE_KEY_AB_TEST_SEED = 'abTestSeed' ; const genRandomString = () => { return Math .random () .toString ( 36 ) .substring ( 2 ) + Math .random () .toString ( 36 ) .substring ( 2 ); } ; export const getOrCreateAbTestSeed = async ( context: Pick < NextPageContext , 'req' | 'res' >) : Promise < AbTestSeed > => { const cookies = parseCookies ( context ); if ( COOKIE_KEY_AB_TEST_SEED in cookies && cookies [ COOKIE_KEY_AB_TEST_SEED ] ) { return { seed: cookies [ COOKIE_KEY_AB_TEST_SEED ] , } ; } const seed = genRandomString (); nookies. set( context , COOKIE_KEY_AB_TEST_SEED , seed , { maxAge: 5 * 365 , path: '/' , httpOnly: false , sameSite: 'lax' , } ); return { seed , } ; } ; 次に、今回のエクスペリエンスをSAMPLE_TESTとし、以下の項目を設定します。 テストを開始するページ: トップページ テストパターンの数: 2 テスト対象の割合: 全てのユーザー 【abTestSetting.ts】 export interface AbTestSetting { experimentId: string ; // OptimizeのエクスペリエンスID triggerPaths?: RegExp [] ; // テスト開始をトリガーするPathを指定 numOfVariants?: number ; // テストパターンの数 targetRatio?: number ;   // 対象とするユーザーの割合。0~1で設定 } // 全てのクエリを含む、トップページが対象 const TopPageRegex = /^\/(\?.*)?$/ ; export const SAMPLE_TEST = { experimentId: 'XXXXXXXXXXXXXXXX' , numOfVariants: 2 , targetRatio: 1 , triggerPaths: TopPageRegex ; } ; export const ABTests: AbTestSetting [] = [ SAMPLE_TEST ] ; 今後複数のエクスペリエンスを実行することを考慮し、ABTestsという変数に配列で格納しておきます。 次に、abTestSeedを受け取ってテストパターンを返す関数を定義します。 OptimizeABテスト用のReact Contextも作成しておきます(詳細は後述します)。 【abTestState.ts】 import { createContext } from 'react' ; import { ABTests , AbTestSetting } from './ABTestSettings' ; import * as crypto from 'crypto' ; import * as hashSha256 from 'sha256-uint8array' ; export type ABTestVariant = { experimentId: string ; variant: number ; } ; // テストパターンを決定する export const determineVariantsForPath = ( path: string , seed: string ) : ABTestVariant [] => { const abTests = ABTests.filter (( setting ) => { if ( ! setting.triggerPaths ) { return true ; } return setting.triggerPaths.some (( pattern ) => pattern.test ( path )); } ); return _determinVariants ( abTests , seed ); } ; const _determinVariants = ( abTests: AbTestSetting [] , seed: string ) : ABTestVariant [] => { const variants = abTests .map (( setting ) => { return { experimentId: setting.experimentId , variant: determineVariant ( setting , seed ), } ; } ) .filter (( variant ) => variant.variant !== NO_VARIANTS ); return variants ; } ; const UINT_MAX = 4294967295 ; export const NO_VARIANTS = -1 ; export const determineVariant = ( setting: AbTestSetting , seed: string ) => { //Seed値をハッシュ化させてパターンを割り振る const digest = hashSha256.createHash () .update ( ` ${ setting .experimentId } - ${ seed } ` ) .digest () .buffer ; const patternBase = new DataView ( digest.slice ( 0 , 4 )) .getUint32 ( 0 ); const numOfPatterns = setting.numOfVariants || 2 ; const targetRatio = setting.targetRatio || 1 ; const pattern = patternBase % numOfPatterns ; if ( targetRatio >= 1 ) { return pattern ; } else { // 比率が1ではない場合は、4~8byte目を使用する const ratioBase = new DataView ( digest.slice ( 4 , 8 )) .getUint32 ( 0 ) / UINT_MAX ; if ( ratioBase < targetRatio ) { return pattern ; } else { return NO_VARIANTS ; } } } ; //グローバルで状態管理するためにReact Contextを使用 export const ABTestContext = createContext ( { seed: 'empty' , } ); //テストパターンを取得。実際にABテストを実施するコンポーネントで使用する export const getVariant = ( setting: AbTestSetting ) : number => { const seed = useContext ( ABTestContext ) .seed ; const variant = determineVariant ( setting , seed ); if ( variant !== NO_VARIANTS ) { return variant ; } else { return undefined ; } } ; 2) Optimizeにテストパターンを送信 abTestSeedを取得する関数を_app.tsx内で実行し、メタ要素を管理するMetadataコンポーネントに渡します。 【_app.tsx】 import { NextPageContext } from 'next' ; import { useRouter } from 'next/router' ; import Metadata from './Metadata' ; import { determineVariantsForPath } from './ABTestState' ; const App = ( { Component , pageProps , abTestSeed } ) => { const router = useRouter (); return ( <> < Metadata abTestVariants = { determineVariantsForPath ( router.asPath , abTestSeed ) } / > < Component { ...pageProps } / > < / > ); } ; App.getInitialProps = async ( appContext: AppContext ) => { const abTestSeed = await getOrCreateAbTestSeed ( appContext.ctx ); return { abTestSeed: abTestSeed.seed } ; } ; export default App ; 【Metadata.tsx】 import React from 'react' ; import Script from 'next/script' ; import { ABTestVariant } from './ABTestState' ; const googleAnalyticsTrackingId = 'YYYYYYYYYYYYYYY' const googleOptimizeTrackingId = 'ZZZZZZZZZZZZZZZ' interface MetadataProps { abTestVariants: ABTestVariant [] ; } const Metadata: React.FC < MetadataProps > = ( props ) => { return ( <> < Script src = { `https://www.googletagmanager.com/gtag/js?id= ${ googleAnalyticsTrackingId } ` } / > < Script id = "gtag-snippet" strategy = "afterInteractive" dangerouslySetInnerHTML = {{ __html: ` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', ' ${ googleAnalyticsTrackingId } ', { page_path: window.location.pathname, experiments: [ ${ props.abTestVariants .map((variant) => { return `{ id:' ${ variant.experimentId } ', variant:' ${ variant.variant } ' }` ; } ) .join(',')}], }); ` , }} / > < / > ); } ; export default Metadata ; これで、トップページにアクセスがあったタイミングでOptimizeにデータが送信されるようになります。 3) テストパターンのグローバル管理、取得 (2)で記述した_app.tsxのコンポーネントをReact Contextでラップすることで、テストパターンをグローバルで状態管理します。 【_app.tsx】 import { ABTestContext } from './ABTestState' ; //...省略... return ( < ABTestContext.Provider value = {{ seed: abTestSeed }} > < GoogleAnalyticsMetadata abTestVariants = { determineVariant ( abTestSeed ) } / > < Component { ...pageProps } / > < /ABTestContext.Provider > ); } ; //...省略... 私たちのプロダクトでは状態管理にRecoilを使用していますが、Recoilの場合、RecoilRootごと(各ページごと)に状態が分割しているためテストパターンを全体で共有できず、React Contextを使用するようにしました。 4) コンテンツの出し分け 最後は実際の表示部分です。 テストパターンを取得し、表示を出しわけます。 【SampleComponent.tsx】 import React from 'react' ; import { getVariant } from './ABTestState' ; import { SAMPLE_TEST } from './ABTestSettings' ; const pattern = [ 'オリジナル' , 'パターンA' ] const SampleComponent: React.FC = ( props ) => { const variant = getVariant ( SAMPLE_TEST ); return ( <> < p > { pattern [ variant ]} < / > <> ); } ; export default SampleComponent ; 上記のコンポーネントでは、テストパターンが0だと「オリジナル」、1だと「パターン1」が表示されるようになります。 ※実施するABテストの内容によっては、コンポーネント自体を別で作って表示を出し分けるような実装でもいいかと思います。テスト内容に応じて調整ください。 以上がプログラム側で必要な実装となります。 ■Optimize側での実装 Optimize側で必要な設定は主に以下の2つです。 1) テストパターンの作成 ABテストに必要な数だけパターンを作成します。 テストの実装は全てプログラム側で行っているため、編集ボタンでの操作は一切不要です。パターンを作成するだけでOKです。 2)ページターゲティングの設定 存在しないサイトのURLを入力してください。正式なURLを入力してしまうとエクスペリエンスがOptimize側で自動的に開始されてしまい、プログラム側で実装した処理よりも優先して開始されてしまいます。ここでは公式リファレンスを参考にして「SERVER_SIDE」と入力しています。画像のようなWarningが出ますが、無視でOKです。 その他 それ以外に必要な設定は特にありません。管理画面にはトラフィックの割り当てやアクティベーションイベントなどを設定する項目がありますが、今回のサーバーサイドでのABテストの場合、それらは全てプログラム側で設定することになるため管理画面側での設定は不要です。 あとは、GAとの連携やOptimizeのインストールが済んでいればエクスペリエンスの開始が可能なので、「開始」ボタンをクリックしてエクスペリエンスを開始させてください。プログラム側からデータが正常に送られているとデータが集計されます。 設定は以上となります。 ■ハマった点 gtag.jsでのデータ送信 Googleの測定サービスにデータを送信するにはgtag.jsというフレームワークを使いますが、公式のサンプルコードではgtag.jsの’set’ディレクティブが記述されているものの、今回の実装だとデータが送信されませんでした。結局’config’ディレクティブに変更して上手くいきましたが、かなり詰まりました。 管理画面側で必要な設定項目 通常のReactアプリ(SPA)でのOptimize実装の文献を読むと「アクティベーションイベントを設定してプログラム側でuseEffectで呼び出す〜」といった説明がありますが、サーバーサイドでのテストの場合はこれらの設定は一切不要です。 上述の通り、管理画面側で必要な設定は主に2つのみです。管理画面はあくまでABテストを行うためだけの土台作りのイメージ、ということを認識する必要がありました。 状態管理 状態管理にはRecoilを使用していますが、私たちのプロダクトでは各ページごとにRecoil Rootで状態を分割しているため、テストパターンの値を全体で共有できませんでした。React Contextを使うことで解決しました。 ABTestSeed生成のタイミング 該当ページにアクセスした時にseed値を生成するようにすると、複数ページにまたがるABテストを実行する場合に矛盾が生じてしまいます。 例)/hogeと/fugaで同一のテストを行いたい場合、/hogeにアクセス(オリジナル) ⇨ /fugaに遷移(テスト発火) ⇨ /hogeにアクセス(パターンA) のように、同じユーザーに対して/hogeの表示内容が変わってしまう可能性があります。 なのでABTestSeedの生成を_app.tsxで実行することで、全てのページにアクセスがあったタイミングでseed値の生成をするようにしています。 ■懸念点 ブラウザ(Optimize管理画面)側でテストの稼働を制御できない ABテストを中断するにはプログラムを修正する必要があります。管理画面でエクスペリエンスを停止すると結果の集計はされなくなりますが、テスト自体はプログラム側で実装しているため、プログラムを修正しない限り継続して稼働し続けてしまいます。 対策として、DBにテストの稼働を管理するためのデータを持たせて制御するような仕組みを検討していく必要があると考えています。 最後に いかがでしたでしょうか。 Server Side RenderingでのABテストの実装は参考文献が少なく、詰まった部分がとても多かったですが、今回無事に実装できてよかったです。これでABテストを行う基盤が整ったので、今後は実際にテストを沢山まわしてプロダクトの分析・グロースに繋げていければと思います。 レバレジーズでは、私のようにエンジニア未経験で入社した社員でも幅広くチャレンジできる環境が整っています。ご興味のある方は、以下のリンクから是非ご応募ください! recruit.jobcan.jp
はじめに 初めまして、レバレジーズ株式会社FEエンジニアの森山です。 今回は、React開発におけるコンポーネントの定義方法の1つの解をご紹介します。 結論 結論を簡潔に記載すると以下です。 ロジックをUIロジックと業務ロジックに切り分ける。 UIとUIロジックは密結合させて再利用性を高める。 UIと業務ロジックは疎結合にして再利用性を高める。 背景や具体的な例は後述します。 なぜこの記事を書いたのか 私が調べた限りではReactにおけるコンポーネントの定義方法におけるベストプラクティスが存在しないからです。 Reactの公式ドキュメントにおいてもコンポーネントの定義方法の方針は記載が無いかと思います。これはReactの思想として開発者がプロジェクトの規模や特性を考慮してある程度の自由度を持って開発できることを尊重したのではないかと考えています。 自由度が高いと開発方法の手段が増えます。手段が多いのでどの開発方法がベストなのかReactの開発者も日々、様々な開発手段を試行錯誤しブログを執筆したり、議論しています。 プロジェクトに合わせて最適な手段を選択できる余地があるのは良いことです。 しかし、その自由度の高さをそのままにしておく。 つまりコンポーネントの定義ルールが曖昧な状態でプロジェクトが進むと以下のようなことが起きる可能性があります。 開発者が各々、自由に開発を進める。 システム内に様々なコンポーネント実装の流派が生まれ、自分の実装が正しいか判断できなくなる。 実装方法の正解が分からず、何をどこに書くべきか自信がないため実装スピードが落ちる。 修正を加える度に複雑さが増し、再利用性も低下する。 最終的にプロジェクト当初では1時間と見積もっていた規模の修正タスクに3時間かかってしまう。 上記を払拭するためにも、FEエンジニアが納得感を持ってコンポーネント実装ができるルールを作る必要がありました。 コンポーネント実装における課題はUIとロジックの関係性 よく議論されるのは以下です。 コンポーネントのUIとロジックを密結合にするのか、疎結合にするのか。 疎結合の場合はHooksを活用するのか、コンポーネント自体を分けるのか。 そもそもReactにおけるロジックとは何か? そもそもこの記事においてのReactにおけるロジックとはreturnでDOMを返す前の処理やUIの表示分岐のことです。 const Component = () => { const [state, setState] = useState(true); const data = fetch('https://api.endpoint'); // ... // ここから上がロジック return ( &lt;h1&gt;{data.title}&lt;/h1&gt; &lt;!-- UI表示の分岐ロジック --&gt; &lt;p&gt;{state ? 'on' : 'off' }&lt;/p&gt; &lt;/&gt; ) } 一言でロジックと言っても、以下の2種類に分類できると考えています。 UIロジック 業務ロジック UIロジック UIロジックとは、UIの制御のみを目的としたロジックです。 具体例としては以下の様な挙動が挙げられます。 トグルボタンのon/offの制御 プルダウンの開閉の制御 const Toggle = ({ isActive }) => { return isActive ? &lt;ActiveToggle/&gt; : &lt;InActiveToggle/&gt; } UIロジックは、「業務に依存しない」純粋なUIのふるまいの制御になります。 業務ロジック 業務ロジックとは業務ルールを実現するためのロジックです。 表示する数値に単位を付ける等の業務独自の「表示データの整形」や、「APIの発行」等が該当します。 const Price = () => { const { data } = fetch('https://api.endpoint'); const { quantity, unitPrice ,taxRate, shippingCost } = data; const basePrice = quantity * unitPrice; const price = (basePrice + shippingCost) * taxRate; return ( &lt;h1&gt;価格: {price}円&lt;/h1&gt; &lt;p&gt;内訳&lt;/p&gt; &lt;ul&gt; &lt;li>個数: {quantity}個&lt;/li> &lt;li>単価: {unitPrice}円&lt;/li> &lt;li>税率: {taxRate}%&lt;/li> &lt;li>送料: {shippingCost}円&lt;/li> &lt;/ul&gt; &lt;/&gt; ) } UIとロジックの密結合、疎結合の使い分け UIとロジックは必ずしも「密結合が良い」とか「疎結合が良い」とは言い切れません。 前述したロジックの分類(UIロジックと業務ロジック)によって変わるかと思います。 コンポーネント単体で見るときれいに切り分けられている記事も見かけます。 しかしその1部品をルール化してプロジェクト全体に適応しようとすると、プロジェクトの規模や業務ロジックの複雑さも起因してどうしてもルールを守るのが苦しい場面に遭遇します。そして苦しいながらもルールを守るために、意味もなく複雑度の高いコンポーネントが生み出されます。 上手くUIとロジックの関係性をルール化するためにはロジックの分類を考慮する必要があります。 私の考えでは以下の使い分けが望ましいです。 UIとUIロジックは密結合 UIと業務ロジックは疎結合 UIロジックと業務ロジックは疎結合 具体的な実装例 具体的にどのように実装すべきか一般的なAtomic designに沿った分類のコードで説明します。 まず各コンポーネントの区分けは以下になります。 ※ templatesとpagesはここでは省きます。 atoms コンポーネントの最小単位。 ロジックは持たない。 ステートレスなコンポーネント。 システム全体で流用できる。 molecules atoms、moleculesの複合コンポーネント。 UIロジックを持つことがある。 ステートレスなコンポーネント。 システム全体で流用できる。 organisms atoms、moleculesの複合コンポーネント。 業務のドメイン情報をDOMに持つことがある。 そのためシステム全体で流用できるとは限らない。 APIとの疎通や業務ロジックを持つことがある。 そのためステートレスとは限らない。 atoms // アクティブなラジオボタン const ActiveRadio = () => { return ( &lt;div style={{ width: 15px; height: 15px; backgroundColor: white; border: 1px solid black; borderRadius: 10px; display: flex; alignItems: center; justifyContent: center; }}&gt; &lt;span style={{ width: 5px; height: 5px; backgroundColor: blue; borderRadius: 10px; }} /&gt; &lt;/div&gt; ) } const Title = ({ text }) => { return &lt;h1 style={{ fontSize: 20px }}&gt;{text}&lt;/h1&gt; } atomsの特徴 コンポーネントの最小単位。 ロジックは持たない。 ステートレスなコンポーネント。 システム全体で流用される。 atoms実装の注意点 atomsでは基本的に return の上にロジックが書かれることはありません。 というのも意識的にロジックを書かないというよりかは、「最小単位」のコンポーネントなので責務も小さく自然と書く必要がなくなるイメージです。 ロジックを書く必要がある場合はそのatomsは「最小単位」として扱うべきでない可能性があります。 更に最小のコンポーネントになり得ないかを疑う余地があるかと思います。 molecules import { ActiveRadio, InActiveRadio } from '~/atoms/radio'; // on/offができるラジオボタン const Radio = ({ isActive }) => { return isActive ? &lt;ActiveRadio /&gt; : &lt;InActiveRadio /&gt; } import { Button } from '~/atoms/button'; // ファイルアップロードフォーム & ボタン const FileUpload = ({ name, text }) => { const ref = useRef(null); const onClickInput = () => ref.current.click(); return ( &lt;Button onClick={onClickInput} >{text}&lt;/Button&gt; &lt;input hidden name={name} ref={ref} type="file" /&gt; &lt;/&gt; ); } moleculesの特徴 atoms、moleculesの複合コンポーネント。 UIロジックを持つことがある。 ステートレスなコンポーネント。 システム全体で流用される。 上記のラジオボタンのようにコード量が少なくても「他のコンポーネントに依存」していればmoleculesに該当します。 ここがatomsとmoleculesの一番の違いです。 molecules実装の注意点① moleculesにおけるUIロジックとUIは前述の通り「密結合」にするのが望ましいです。 なぜなら、依存関係が簡潔になりやすいためです。 例えば、以下のようにUIロジックを Custom hook で共通化したとします。 全てのpulldownに共通する処理 // usePulldown.ts export const usePulldown = () => { return ... } 一般的なプルダウン // Pulldown.tsx const Pulldown = () => { const pulldownProps = usePulldown() return &lt;Pulldown {...plldownProps}/&gt; } 検索可能なプルダウン // SearchPulldown.tsx const SearchPulldown = () => { const pulldownProps = usePulldown() // ... SearchPulldown独自のロジック return ( &lt;!-- other component --&gt; &lt;Pulldown {...plldownProps}/&gt; &lt;/&gt; ) } 複数選択可能なプルダウン // MultiPulldown.tsx const MultiPulldown = () => { const pulldownProps = usePulldown() // ... MultiPulldown独自のロジック return ( &lt;!-- other component --&gt; &lt;Pulldown {...plldownProps}/&gt; &lt;/&gt; ) } ここで usePulldown の処理の中で SearchPulldown.tsx のみに例外的な処理の追加が必要になったとします。 根本的な対処としては usePulldown の中から例外的な処理が入る部分を切り出して他の Pulldown や MultiPulldown にも変更箇所のコードを移植することになるかと思います。 しかし元々一元管理されていたコードをわざわざ重複させる書き方はDRY原則にも反します。 大抵は以下のように if 文を一行入れて例外的な処理を走らせるような直感的な対処をしてしまいます。 // usePulldown.ts export const usePulldown = (type) => { + if(type === 'searchPulldown') doSomething(); return ... } 依存される側の usePulldown が依存する側のコンポーネントの情報を保持することになります。 こうなると双方向に依存が発生し依存関係がおかしなことになります。 これはReactによらずプログラミング全般として良くない実装かと思います。 しかしReact開発で上記のように似たようなふるまいのコンポーネントが複数生成されて開発規模が大きくなると自然とやってしまいがちです。 そもそもこういった修正が発生しないためにも「UIロジック」と「DOM」は密結合に実装し、コンポーネント同士の独立性を高める方が各コンポーネントの拡張性も高くなります。 molecules層で複雑なUIのふるまいを持つことは少ないので1ファイルに「UIロジック」と「DOM」を密結合に実装しても50行前後に収めて可読性を担保することも可能なはずです。 molecules実装の注意点② moleculesコンポーネントはシステム全体で活用できるレベルの再利用性を持つことが望ましいです。 システム全体で活用できるとは特定の業務ドメインに縛られないということです。 例えば以下のファイルアップロードボタンは汎用性が高く基本的にどんな場面でも活用できるかと思います。 import { Button } from '~/atoms/button'; // ファイルアップロードフォーム & ボタン const FileUpload = ({ name, text }) => { const ref = useRef(null); const onClickInput = () => ref.current.click(); return ( &lt;Button onClick={onClickInput} >{text}&lt;/Button&gt; &lt;input hidden name={name} ref={ref} type="file" /> &lt;/&gt; ); } 逆に特定の業務ドメインに縛られているとは以下のような状態です。 違いはボタンの名前が固定値になっただけです。 import { Button } from '~/atoms/button'; // ファイルアップロードフォーム & ボタン const FileUpload = ({ name }) => { const ref = useRef(null); const onClickInput = () => ref.current.click(); return ( &lt;Button onClick={onClickInput} >プロフィール画像のアップロード&lt;/Button&gt; &lt;input hidden name={name} ref={ref} type="file" /> &lt;/&gt; ); } どんなシステムを開発しているかの前提もないですが、 上記のファイルアップロードボタンはユーザ情報の登録や編集を担う画面でしか活用できないコンポーネントになることが想像できます。 もしシステムが商品登録等の機能を持っていたらそちらでもファイルアップロードボタンは必要になりそうです。 しかし上記のように「特定の業務ドメイン」に限定されたコンポーネントだと再利用はできません。 再利用性の低いコンポーネントがmoleculesレベルで存在しているとシステムの規模が大きくなった時に以下のどちらかの苦しい対処が発生します。 利用箇所が限定的なコンポーネントが極端に増える 無理くりatomsをpages内で組み合わせて実装 なのでmoleculesでは特定の業務ドメインに縛られないコンポーネントであるべきです。 特定の業務ドメインとは何か 特定の業務ドメインとは何かについてさらに詳しく触れます。 単純に「業務ドメイン」とは言わずに「特定の業務ドメイン」と呼んでいるのには意味があります。 極端な例ですが、開発するシステムが商品管理システムでそれ以外の情報を扱わなかったとします。 商品管理システム 商品登録機能 商品編集機能 商品削除機能 この場合には、特定の業務ドメインは「商品登録機能、商品編集機能、商品削除機能」が該当します。 なので以下のようなコンポーネントが実装されてもシステム全体で活用できるのでmoleculesとして成立します。 import { Button } from '~/atoms/button'; // ファイルアップロードフォーム & ボタン const FileUpload = ({ name }) => { const ref = useRef(null); const onClickInput = () => ref.current.click(); return ( &lt;Button onClick={onClickInput} >商品画像のアップロード&lt;/Button&gt; &lt;input hidden name={name} ref={ref} type="file" /> &lt;/&gt; ); } ただシステムが以下のような構成だと上記のコンポーネントはmoleculesとして成立しません。 フリーマーケットシステム ユーザ管理機能 登録、編集、削除 商品管理機能 登録、編集、削除 molecules実装の注意点まとめ 長くなりましたが特に注意すべきは以下の2点です。 UIとUIロジックが密結合になっていること 特定の業務ドメインに縛られないこと organisms import { Title, Paragraph } from '~/atoms' import { PriceList } from '~/molecules/PriceList' // 価格 const Price = () => { const { data } = fetch('https://api.endpoint') const { quantity, unitPrice ,taxRate, shippingCost } = data; const basePrice = quantity * unitPrice; const price = (basePrice + shippingCost) * taxRate; return ( &lt;Title&gt;価格: {price}円&lt;/Title&gt; &lt;Paragraph&gt;内訳&lt;/Paragraph&gt; &lt;PriceList quantity={quantity} taxRate={taxRate} shippingCost={shippingCost} /> &lt;/&gt; ) } organismsの特徴 atoms、moleculesの複合コンポーネント。 業務のドメイン情報をDOMに持つことがある。 APIとの疎通や業務ロジックを持つことがある。 organismsの実装の注意点 業務ロジックとUI(DOM)を疎結合にすることです。 organismsは上記の例のように業務ロジックや業務のドメイン情報をDOMに持ちます。 ただ上記のコード例は悪い例です。 以下のように業務ロジックとUIが密結合していると悪い点が3点あります。 再利用性の低下 データ構造が見えない 業務ロジックをDOM上で実装できてしまう 1. 再利用性の低下 業務ロジックとUIが密結合しているのでUIを再利用したくても限られた場面でしか活用できません。 2. データ構造が見えない コンポーネントにpropsが存在しないので最終的にどんな構造のデータをレンダリングされるのか読み取るためにはDOMを全て読み解く必要があります。上記のコードはまだシンプルですがDOMの中で条件分岐が発生したりコード量が増えるとかなりしんどくなります。 3. 業務ロジックをDOM上で実装できてしまう 業務ロジックがDOM上に実装されると、ロジックの記載場所が以下の2パターンになります。 returnより上 DOMの中 書く場所が複数ありルールも存在しないと開発する時にどちらに書くべきか迷いが生まれます。 例えば、先程提示したコードにおいて業務上の仕様が変わって会員の時は「価格が10%off」になるという業務ロジックが加わったとします。 一番手っ取り早くしようとすると最終的にレンダリングされている価格に計算処理を加える実装になりそうです。 import { Title } from '~/atoms/Title' import { PriceList } from '~/molecules/PriceList' // 価格 const Price = () => { const { data } = fetch('https://api.endpoint') - const { quantity, unitPrice ,taxRate, shippingCost } = data + const { quantity, unitPrice ,taxRate, shippingCost, isMember } = data cons t basePrice = quantity cons unitPrice t price = (basePrice + shippingCost) taxRate return ( - &lt;Title&gt;価格: {price}円&lt;/Title&gt; + &lt;Title&gt;価格: {isMember ? price * 0.9 : price} &lt;p&gt;内訳&lt;/p&gt; &lt;PriceList quantity={quantity} taxRate={taxRate} shippingCost={shippingCost} /> &lt;/&gt; ) } この例ではコードの行数が短いですが、実際のorganismsコンポーネントになるとコードの行数が長くなりがちで自然とこういった楽な実装に流されることが多いです。 そして何より厄介なのは上記の例のように三項演算子1つだと対して悪くなさそうに見えることです。 これに更にもう一種類、ゴールド会員が追加されるとどうでしょうか? import { Title } from '~/atoms/Title' import { PriceList } from '~/molecules/PriceList' // 価格 const Price = () => { const { data } = fetch('https://api.endpoint') - const { quantity, unitPrice ,taxRate, shippingCost, isMember } = data + const { quantity, unitPrice ,taxRate, shippingCost, isMember, isGoldMember } = data cons t basePrice = quantity cons unitPrice t price = (basePrice + shippingCost) taxRate return ( - &lt;Title&gt;価格: {isMember ? price * 0.9 : price} + &lt;Title&gt;価格: {isGoldMember + ? price * 0.8 + : isMember + ? price * 0.9 + : price + }円 &lt;p&gt;内訳&lt;/p&gt; &lt;PriceList quantity={quantity} taxRate={taxRate} shippingCost={shippingCost} /> &lt;/&gt; ) } 元々のコードでDOMに三項演算子が実装されていると自然とそこに追加する実装をしてしまうことがあると思います。 こうなると業務ロジックが return の上だけでなくDOMの中にも存在することになります。 ロジックの実装箇所がルールもなく複数箇所になることで可読性が一気に落ちます。 DOMを再利用するために別ファイルに切り出そうとしてもロジックを含むことで単純にDOMだけを切り出すこともできなくなります。 打開策 上記の良くない2点を打開するためには、業務ロジックとUIを疎結合にする必要があります。 具体的には、organismsの中でもコンポーネントを2つに分けます。 organisms/ ├ Presentations/ │ └ Price.tsx (UIを責務としたコンポーネント) │ └ Containers/ └ PriceContainer.tsx (業務ロジックを責務としたコンポーネント) 今後、2つのコンポーネントは以下の呼び方をします。 UIを責務としたコンポーネント: Presentational Components 業務ロジックを責務としたコンポーネント: Container Components PresentationalComponents // organisms/Price.tsx import { Title } from '~/atoms/Title' import { PriceList } from '~/molecules/PriceList' // 価格 const Price = ({ price, quantity, taxRate, shippingCost }) => { return ( &lt;Title&gt;価格: {price}円&lt;/Title&gt; &lt;p&gt;内訳&lt;/p&gt; &lt;PriceList quantity={quantity} taxRate={taxRate} shippingCost={shippingCost} /> &lt;/&gt; ) } PresentationalComponentsの特徴 ステートレスなコンポーネントで「 どのようにデータを表示するか? 」を責務とします。 UI表示のみが責任範囲として切り出されているので別の場所で再利用も可能になります。 PresentationalCompornents は業務ロジックを持たないため、storybook等のUIのライブラリ化も容易にできます。 propsが表示すべきデータの構造そのものになります。 PresentationalComponents実装の注意点 propsをAPIのデータ構造に依存させないことです。 あくまでも「どのようにデータを表示するか?」に注目します。 APIのデータ構造に依存するとUIの構造とデータ構造が乖離して苦しい実装を強いられたりします。 APIのデータ構造の変更が頻発し、FEの改修工数が増えるとBE側がFEの負荷を軽減するためにUIドリブンなAPI定義を意識したりします。 そうなってしまうとBE実装がUIに引っ張られて業務ドメインに沿った実装ができずFEもBEも拡張性の低い実装になったりしてしまいます。 ContainerComponents // organisms/PriceContainer.tsx const PriceContainer = () => { const { data } = fetch('https://api.endpoint') const { quantity, unitPrice ,taxRate, shippingCost } = data; const basePrice = quantity * unitPrice; const price = (basePrice + shippingCost) * taxRate; return ( &lt;Price price={price} quantity={quantity} taxRate={taxRate} shippingCost={shippingCost} /&gt; ) } ContainerComponentsの特徴 業務ロジックの実装に着目したコンポーネントになります。 どう表示されるかの責任はすべて PresentationalComponents に委ねます。 ContainerComponents では位置の調整や余白のためにスタイルを当てることもありません。 Presentational/Container Componentsの実装方針 organismsの中でも PresentationalComponents と ContainerComponents はディレクトリも区分けして明確に違うものとして定義しておくのが良いです。 そうすることで修正が必要な場合にどちらのコンポーネントを修正すべきかも明確に判断が付きます。 ただコンポーネントの実装を始める前から Presentational/Container Components をどういった構造にすべきか設計することは難易度が非常に高いです。 まずは Presentational Components をひたすら実装していき、画面まで実装するのが望ましいです。 その後APIをpagesコンポーネントで繋ぎこみます。 pagesでAPIを発行するとorganismsの Presentational Components を辿って末端のatomsコンポーネントまでデータをバケツリレーしていくことになります。 この時にpropsとして受け取ったデータを無加工でそのまま更に末端のコンポーネントに渡す場面が発生するはずです。 その場面で Container Components の実装を検討していくというのが手戻りを少なくできます。 結論 まとめですが、結論以下のような構成・ルールでコンポーネントを分割しました。 atoms コンポーネントの最小単位。 ロジックは持たない。 ステートレスなコンポーネント。 システム全体で流用できる。 molecules atoms、moleculesの複合コンポーネント。 UIロジックを持つことがある。 ステートレスなコンポーネント。 システム全体で流用できる。 organisms atoms、moleculesの複合コンポーネント。 業務のドメイン情報をDOMに持つことがある。 そのためシステム全体で流用できるとは限らない。 APIとの疎通や業務ロジックを持つことがある。 PresentationalComponentsとContainerComponentsを分ける。 得た恩恵 Atomic designの区分が明確化 atoms/moleculesの再利用性・拡張性の担保 organismsのUIと業務ロジックの責務をコンポーネント単位で分離 全体を通してどこに何を実装すべきかが判断できるようになり無理な実装が生まれない仕組みを実現できました。 おわりに いかがでしたでしょうか。 上記のコンポーネント開発の構成がどのプロジェクトでも上手く行くとは限らないですが、何か1つでも参考になる箇所があれば幸いです。私の所属するHRテック事業部は発足してまだ日が浅く、プロダクトの状況も0-1フェーズにあります。なのでこういったどういう構成で開発を進めるべきかなどを検討・決定していける機会が多いです。この記事をご覧になり、この事業部を一緒に盛り上げてみたいなと思う方が1人でも増えれば嬉しく思います!(詳しくは こちら ) var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&amp;").replace(/ =g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(j g.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a ))*-->)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^ $|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s '"]+/}},cdata:{pattern:/ /i,greedy:!0},tag:{pattern:/ \/=$ \/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^ \/]+/,inside:{punctuation:/^ \/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&amp;/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^ $)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^ $/i;var t={"included-cdata":{pattern:/ /i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("( ]*>)(?: ))*\\]\\]>|(?! )".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; !function(s){var e=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|"+e.source+")*?(?:;|(?=\\s*\\{))"),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+e.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+e.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+e.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:e,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism); Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[ ]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp("(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])"),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==| >>?=?|[-+*/%&|^!= ]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp("((?:^|[^$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)/(?:(?:\\[(?:[^\\]\\\\\r\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|/\\*(?:[^*]|\\*(?!/))*\\*/)*(?:$|[\r\n,.;:})\\]]|//))"),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute("on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)","javascript")),Prism.languages.js=Prism.languages.javascript; !function(e){e.languages.diff={coord:[/^(?:\*{3}|-{3}|\+{3}).*$/m,/^@@.*@@$/m,/^\d.*$/m]};var n={"deleted-sign":"-","deleted-arrow":" ",unchanged:" ",diff:"!"};Object.keys(n).forEach((function(a){var i=n[a],r=[];/^\w+$/.test(a)||r.push(/\w+/.exec(a)[0]),"diff"===a&&r.push("bold"),e.languages.diff[a]={pattern:RegExp("^(?:["+i+"].*(?:\r\n?|\n|(?![\\s\\S])))+","m"),alias:r,inside:{line:{pattern:/(.)(?=[\s\S]).*(?:\r\n?|\n)?/,lookbehind:!0},prefix:{pattern:/[\s\S]/,alias:/\w+/.exec(a)[0]}}}})),Object.defineProperty(e.languages.diff,"PREFIXES",{value:n})}(Prism); !function(t){var n=t.util.clone(t.languages.javascript),e="(?:\\{ *\\.{3}(?:[^{}]| )*\\})";function a(t,n){return t=t.replace(/ /g,(function(){return"(?:\\s|//.*(?!.)|/\\*(?:[^*]|\\*(?!/))\\*/)"})).replace(/ /g,(function(){return"(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\})"})).replace(/ /g,(function(){return e})),RegExp(t,n)}e=a(e).source,t.languages.jsx=t.languages.extend("markup",n),t.languages.jsx.tag.pattern=a(" +(?:[\\w.:$-]+(?:=(?:\"(?:\\\\[^]|[^\\\\\"])*\"|'(?:\\\\[^]|[^\\\\'])*'|[^\\s{'\"/>=]+| ))?| ))* */?)?>"),t.languages.jsx.tag.inside.tag.pattern=/^ \/]*/,t.languages.jsx.tag.inside["attr-value"].pattern=/=(?!\{)(?:"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*'|[^\s'">]+)/,t.languages.jsx.tag.inside.tag.inside["class-name"]=/^[A-Z]\w*(?:\.[A-Z]\w*)*$/,t.languages.jsx.tag.inside.comment=n.comment,t.languages.insertBefore("inside","attr-name",{spread:{pattern:a(" "),inside:t.languages.jsx}},t.languages.jsx.tag),t.languages.insertBefore("inside","special-attr",{script:{pattern:a("= "),alias:"language-javascript",inside:{"script-punctuation":{pattern:/^=(?=\{)/,alias:"punctuation"},rest:t.languages.jsx}}},t.languages.jsx.tag);var s=function(t){return t?"string"==typeof t?t:"string"==typeof t.content?t.content:t.content.map(s).join(""):""},g=function(n){for(var e=[],a=0;a 0&&e[e.length-1].tagName===s(o.content[0].content[1])&&e.pop():"/>"===o.content[o.content.length-1].content||e.push({tagName:s(o.content[0].content[1]),openedBraces:0}):e.length>0&&"punctuation"===o.type&&"{"===o.content?e[e.length-1].openedBraces++:e.length>0&&e[e.length-1].openedBraces>0&&"punctuation"===o.type&&"}"===o.content?e[e.length-1].openedBraces--:i=!0),(i||"string"==typeof o)&&e.length>0&&0===e[e.length-1].openedBraces){var r=s(o);a 0&&("string"==typeof n[a-1]||"plain-text"===n[a-1].type)&&(r=s(n[a-1])+r,n.splice(a-1,1),a--),n[a]=new t.Token("plain-text",r,null,r)}o.content&&"string"!=typeof o.content&&g(o.content)}};t.hooks.add("after-tokenize",(function(t){"jsx"!==t.language&&"tsx"!==t.language||g(t.tokens)}))}(Prism); !function(e){e.languages.typescript=e.languages.extend("javascript",{"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|type)\s+)(?!keyof\b)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?:\s* ]| ]| ]*>)*>)*>)?/,lookbehind:!0,greedy:!0,inside:null},builtin:/\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\b/}),e.languages.typescript.keyword.push(/\b(?:abstract|declare|is|keyof|readonly|require)\b/,/\b(?:asserts|infer|interface|module|namespace|type)\b(?=\s*(?:[{_$a-zA-Z\xA0-\uFFFF]|$))/,/\btype\b(?=\s*(?:[\{*]|$))/),delete e.languages.typescript.parameter,delete e.languages.typescript["literal-property"];var s=e.languages.extend("typescript",{});delete s["class-name"],e.languages.typescript["class-name"].inside=s,e.languages.insertBefore("typescript","function",{decorator:{pattern:/@[$\w\xA0-\uFFFF]+/,inside:{at:{pattern:/^@/,alias:"operator"},function:/^[\s\S]+/}},"generic-function":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s* ]| ]| ]*>)*>)*>(?=\s*\()/,greedy:!0,inside:{function:/^#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*/,generic:{pattern:/ \/=$ \/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/g,a=RegExp("(?:__|[^\r\n<])*(?:\r\n?|\n|(?:__|[^\r\n<])(?![^\r\n]))".replace(/__/g,(function(){return i.source})),"gi"),s=!1;Prism.hooks.add("before-sanity-check",(function(i){var a=i.language;e.test(a)&&!i.grammar&&(i.grammar=Prism.languages[a]=Prism.languages.diff)})),Prism.hooks.add("before-tokenize",(function(i){s||Prism.languages.diff||Prism.plugins.autoloader||(s=!0,console.warn("Prism's Diff Highlight plugin requires the Diff language definition (prism-diff.js).Make sure the language definition is loaded or use Prism's Autoloader plugin."));var a=i.language;e.test(a)&&!Prism.languages[a]&&(Prism.languages[a]=Prism.languages.diff)})),Prism.hooks.add("wrap",(function(s){var r,n;if("diff"!==s.language){var g=e.exec(s.language);if(!g)return;r=g[1],n=Prism.languages[r]}var f=Prism.languages.diff&&Prism.languages.diff.PREFIXES;if(f&&s.type in f){var u,l=s.content.replace(i,"").replace(/&lt;/g,"<").replace(/&amp;/g,"&"),t=l.replace(/(^|[\r\n])./g,"$1");u=n?Prism.highlight(t,n,r):Prism.util.encode(t);var o,m=new Prism.Token("prefix",f[s.type],[/\w+/.exec(s.type)[0]]),d=Prism.Token.stringify(m,s.language),c=[];for(a.lastIndex=0;o=a.exec(u);)c.push(d+o[0]);/(?:^|[\r\n]).$/.test(l)&&c.push(d),s.content=c.join(""),n&&s.classes.push("language-"+r)}}))}}(); code[class*=language-],pre[class*=language-]{color:#f8f8f2;background:0 0;text-shadow:0 1px rgba(0,0,0,.3);font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre)>code[class*=language-],pre[class*=language-]{background:#272822}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#8292a2!important}.token.punctuation{color:#DA71D6!important}.token.namespace{opacity:.7!important}.token.constant,.token.deleted,.token.property,.token.symbol,.token.tag{color:#f92672!important}.token.boolean,.token.number{color:#ae81ff!important}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#CE9278!important}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f8f8f2!important}.token.atrule,.token.attr-value,.token.class-name,.token.function{color:#D4D59A!important}.token.keyword{color:#4689CC!important}.token.important,.token.regex{color:#fd971f!important}.token.bold,.token.important{font-weight:700!important}.token.italic{font-style:italic}.token.entity{cursor:help} pre.diff-highlight>code .token.deleted:not(.prefix),pre>code.diff-highlight .token.deleted:not(.prefix){background-color:rgba(255,0,0,.1);color:inherit;display:block}pre.diff-highlight>code .token.inserted:not(.prefix),pre>code.diff-highlight .token.inserted:not(.prefix){background-color:rgba(0,255,128,.1);color:inherit;display:block} .entry h1 { font-weight: 700; } .entry h2 { border:none; font-weight: 700; } .language-tsx { color: #43B3FE!important; filter: contrast(110%); } .language-tsx .boolean { color: #43B3FE!important; } .language-tsx .function-variable { color: #41C0A1!important; } .language-tsx .plain-text { color: #8292a2!important } .language-tsx .tag { color: #41C0A1!important } .language-tsx .tag .punctuation { color: #8292a2!important } .language-tsx .language-javascript{ color: #D5D5A4!important } .language-diff .inserted{ color: #41C0A1!important }
はじめに  こんにちは、レバレジーズ株式会社エンジニアの住村です。 現在、私が開発に関わっているレバテックでは、IT人材向けのフリーランス事業を開始してから成長フェーズを経て、2022年現在では市場のトップシェアを獲得しています。 レバテックでは、今後も市場のトップシェアを取り続け、トップであることを活かしてエンジニア業界を主導して流れを作り、業界全体を良い方向に動かしていくことを目指しています。そのためには、エンジニアも目先の開発だけに囚われず、職種も超えて協力して事業を創っていく必要があります。 今回は、認証基盤システムの開発を通じて、エンジニアが今後の事業戦略を支えるためのシステム開発をどのように進めたかの事例を紹介していきたいと思います。 導入背景 レバテックの事業戦略  レバテックでは、フリーランス・正社員・未経験からのスタートなど、さまざまな形で職を探しているエンジニア・クリエイターと企業のマッチングを支援しています。 レバテックのサービス一覧  一度サービスを利用したら終わりではなく、プログラミングを学ぶフェーズ(カレッジ)から、就職(ルーキー)・転職(キャリア)といったサービスを継続的に受けることが可能です。過去の蓄積データを元にエンジニアはより充実したサービスを受けることができます。エンジニアをターゲットとしたサービスを提供している関係上、ユーザーの行動に合わせて当初とは異なるサービスを提供することもあります。 サービスの利用イメージ  また、継続して顧客に利用してもらうことでファン化を促進し、顧客生涯価値(LTV)を高めるだけでなく、サービスの情報を知り合いのエンジニアに紹介してもらう、新規に展開する別サービスの潜在顧客になるなど、今後のマーケティング活動にも繋げることができます。 今回は、この「ユーザーに継続してサービスを使い続けてもらう」事業戦略を実現するために、根幹となる認証基盤システムを構築していきました。 システム課題  前述の事業戦略の実現にあたり、1つのサービスを利用したユーザーがその後も別のサービスをシームレスに利用し続けるためには、システムに大きな課題が存在しました。 それは、これまでのレバテックでは各種サービスが使い切りのビジネスモデルであったため、システムもサービスの開始に合わせて都度構築していたことです。 既存システム構成  その際、認証機能も新規システムを構築する度に作成し、そのシステム内でのみ管理しているため、ユーザーはレバテックの別のサービスを利用する度にアカウントを作成しなければなりませんでした。 また、同じ処理を別々のシステムで開発していたために無駄な開発コストがかかっていたのも事実です。それ以外にも、あるサービスではSNSログインが使えるが別のサービスではEmail/PWでしかログインできない、パスワードに使える文字列の長さや種類が異なる、といったように認証要件も統一されていませんでした。 既存システム課題  今後の事業戦略を実現するため、シームレスにサービス間を利用できるようにするにはこれらの問題点も解消する必要がありました。 そのため、レバテックでは共通の認証基盤を作成し、既存サービスの認証方法を統一する判断に至りました。 今回の構成 技術選定理由  今回のシステムを構築するにあたり、前述の事業戦略からサービス間を横断的にアクセスできる必要性があるため、まずサービス間でのSSOログインを実現する必要がありました。 SSOログインの実現にあたり、大枠の方針としてはAuth0やCognitoやFirebaseといった外部サービスを利用するか、各システムで認証プロセス管理するライブラリを用意するなどの自社で開発するかの2つが選択肢として挙がりました。 SSOログイン機能を開発するには大きく分けて認証プロセスの管理と認証情報の管理の2つの機能が必要です。  今回は認証プロセスに独自仕様がないことと、実装コストを削減するために外部サービスのALBの機能を利用しました。 その上で、認証情報の管理は既存システムからの移行やアカウントの統合といった独自仕様があったため、独自で実装する選択を取りました。 また、認証プロトコルにはALBでサポートされておりセキュリティ信頼性の高いOpenID Connect(OIDC)を採用しました。 ALB 認証プロセス 認証プロセス  今回の構成では、 ・クライアントのログインセッションの管理 ・セッションの状態に応じたログイン画面への振り分け ・認証成功時のアクセストークンの管理 ・認証が完了したセッションへのJWTの発行 などの認証プロセスの管理をALBへ任せています。 ALBを利用しない場合はこれらをサイトで管理する必要がありましたが、今回の構成ではALBが管理するため、共通認証基盤の利用に必要な対応が大幅に削減できます。 そのため、既存のサイトはJWTの有無で認証状態を、JWTを検証してどのユーザーがアクセスしているかチェックするだけで良くなります。認証の仕様はOpenID Providerが共通で持っているため、認証仕様を一元管理することで全てのサイトで認証のセキュリティレベルを統一できます。 新規サイト構築時  他にも新規にサイトを構築する場合、共通の認証基盤を利用するにはALBの設定を追加すれば、後はJWTの検証の仕組みを入れるだけで済みます。 これにより、今後の事業展開でサービスを増やす場合のハードルを大きく下げることができました。 思わぬ落とし穴  今回構築を行い、分かった点ですが、ALBを利用する場合は認証プロセスを15分以内に完了しない場合はALBから401エラーがクライアントに返却される仕様がありました。 これに関してはAWSの公式Docにも記載がありますが、このタイムアウトの期間は変更も削除もできない仕様になっています。 今回はアプリ側でタイムアウト期限を過ぎる場合の挙動を制御することで対応しましたが、ALBでタイムアウト時の遷移先が制御できると嬉しいですね。 おわりに  今回の記事では、レバテックの今後の事業戦略と、実現に必要なSSOログインの認証基盤システムをどのように構築したかについて紹介しました。 レバレジーズでは、今回の事例のように短期的な開発だけでなく事業の長期戦略に必要なシステムをエンジニアが主導して進めることができます。 それは、事業の方向性議論にエンジニアも参画するためです。  レバレジーズでは、エンジニアとしてシステムの開発だけでなく、事業の未来を考えサービスを創っていくことに意欲のある仲間を募集中です!ご興味のある方は、以下のリンクから是非ご応募ください。 https://recruit.jobcan.jp/leverages/
はじめに こんにちは、2022年度新卒でレバレジーズ株式会社に入社した河原です。 現在私は、若年層領域の事業を展開する『ハタラクティブ』や『キャリアチケット』というサービスで、営業職が使用するSFA開発 *1 に携わっています。 機能の多いSFAでの開発業務や業務・業界理解など難しいことが多いですが、営業職の社員が同じ職場にいて、日々私たちが開発したシステムを使って仕事しているところを見かけると、とてもやりがいを感じられます! 本記事では、先日行われたレバレジーズエンジニア組織テックフェスでのテックトークセッションの内容をご紹介します!また、私が従事しているSFA開発にどうすれば活かせるかを考え、クラウドコンテナサービスの調査・検証してみました。 テックフェス テックフェスは、レバレジーズのエンジニア組織メンバー全員を対象としたテクノロジーの祭典です。弊社エンジニアの技術力を向上させ、より良いサービスを世の中に提供できるようにするために企画されました。 今回、記念すべき第一回目のテックフェスは、以下のようなタイムスケジュールで行われました。 テックトーク 今回は、テックフェス後半で行われたテックトークでの内容をご紹介したいと思います。 テックトークのコーナーでは、各事業部のシステム開発を行う開発チームリーダークラスのエンジニアが、開発を通じて得られた経験のふりかえりや技術の活用法についての話を5〜10分の時間でプレゼンしました。テックトークのタイトルは以下の通りです。 テックトーク - トークテーマ一覧 インフラリソースや関数型言語など技術にフォーカスした話から、事業部側と密に連携する開発体制についてなどレバレジーズの開発組織ならではの話もあり、短い時間ながらもとても役に立つ内容でした! テックトークの内容を開発に活かしてみたい! 今回のテックトークに刺激を受け、私のチームのシステム開発に活かしてみたい!という気持ちが生まれました。私は様々なトークテーマの中から、現在の私の開発に活かせるものがないかを模索しました。 開発チームリーダーにSFA開発の現状を確認したところ、以下のようなインフラの構想があることがわかりました。 現状:開発環境のみオンプレミスサーバーで作業している 今後:Docker・Github Actions・クラウドコンテナサービスを利用した自動デプロイ環境を構築したい しかし、この環境構築の担当エンジニアは決まっておらず技術調査もあまり進んでいない状況でした。 そこで、テックトークでの 『Cloud Runへのデプロイ自動化が簡単で脳汁が出てしまった話』 の内容をさらに深ぼり、クラウドのコンテナリソースについて調査・検証してみました! 技術調査・比較 テックトークは主に以下のような内容でした。 Cloud Runを使えばコンテナの自動デプロイが簡単に行えるそうです。 コンテナはベンダーロックインされない特徴を持ち、昨今のマイクロサービスアーキテクチャの実現には不可欠な仮想化技術です。 テックトーク - GCP Cloud Runの説明抜粋 本当に簡単にデプロイやCI/CDが設定できるのでしょうか?また、他にはどんなコンテナリソースがあり、それぞれの違いは何があるのでしょうか? その違いを知っておきたく思い、クラウドサービスを調査してみました! テックトークの内容はGCPであり、普段の開発ではAWSを使っていることから、2つのクラウドサービスに対して調査を行います(ちなみに私はコンテナリソースに関してはほぼ無知な状態でした…)。調査した内容をカテゴリ分けすると以下のようになります。 AWS *2 , GCP, OSS *3 の比較 上記のように、同じサーバーレスコンテナリソース同士にも細かな違いが存在するそうですが、それはどのような箇所なのでしょうか? 調査を一通り終え、それぞれのコンテナリソースの違いが大まかにわかってきたところでGCP・AWSのコンテナリソースを簡単に検証してみます! コンテナ自動デプロイ環境構築 今回は、以下の2つのクラウドサービスを用いて検証を行います。 GCP: Cloud Run + Cloud Build フルマネージド型コンテナデプロイサービス AWS: ECR + ECS + Fargate フルマネージド型コンテナオーケストレーションサービス GCPの Cloud Run は今回のテックトークで登場し、その手軽さを実感したいと思ったため選択しました。AWSにはCloud Runに似たサービスとしてApp Runnerがあります。App Runnerの設定や機能はCloud Runとほぼ同様のため、コンテナリソース間の違いをあまり実感できないのではないかと思い、今回はApp Runnerより少し複雑な設定や調節が可能な 『ECR + ECS + Fargate』 を使って検証してみます。 また、今回はクラウドサービスの比較検証がメインであるため、WebサーバーであるNginxを使った簡単なコンテナ環境で動作確認を行います!下記構成でGithubに登録した状態で検証スタートです! .├── Dockerfile └── html └── index.html 【GCP】Cloud Run + Cloud Build まずは、テックトークでも登場したCloud Runを使ってみます。GCPのコンソール上の簡単な操作で自動デプロイ設定が可能なようです。実際に試してみましょう! GCPにProjectを作成し、Cloud RunとCloud BuildのAPIを有効にします。テックトークで説明された項目を作成したGithubのリポジトリで設定していきます。 今回は、master branchのコンテナをデプロイする設定にしてみました 次に作成するコンテナの設定をしていきます。今回は検証なのでユーザー認証の設定はせず、リクエストの処理中のみコンテナを起動する設定にしてみます。 設定が終わり、GCPがコンテナの準備を始めます。 数分待つと、Cloud Runでコンテナが起動 作成されたコンテナはhttpsで自動にルーティングされます。 URLにアクセスするとGithubにpushしたコードがデプロイされていることが確認できました。本当に簡単ですね…。 作成したNginxコンテナ 次にCD(Continuous Deployment:継続的デプロイ)の設定をしていきます。 コンソールの「継続的デプロイの編集」を押し、以下のように編集していきます。今回はmasterブランチへpushされたイベントでコンテナがデプロイされる設定を行ってみました。 試しにコードに、簡単なcssと画像を追加し、Githubにpushしてみます。 すると、そのイベントを検知し、デプロイが始まります。 しばらくするとコンテナが起動し、変更を確認することができました。CDの設定まで非常に簡単にできてしまいます。 【AWS】ECR + ECS + Fargate 次は、AWSのコンテナ用サーバーレス環境のFargateを使って、ECRとECSでコンテナをデプロイしてみます。 ECSはフルマネージド型コンテナオーケストレーションサービスと呼ばれており、Cloud Runよりも設定や導入は複雑ですが、その分高度な設定やコンテナ同士の連携が可能になります。FargateはECSやEKS用のサーバーレス環境であり、AWSユーザーはEC2のようなホストマシンの複雑な設定が必要なくコンテナを運用できます。 GithubとECRを連携させるために、Github Actionsの設定を行います。 初めにAWSのIAMで『AmazonEC2ContainerRegistryPowerUser』のポリシーを設定したユーザーを作成し、アクセスキーを発行します。発行したアクセスキーペアと、あらかじめ作成したECRのリポジトリ名をGithub>Setting>Secrets>Actionsに設定しておきます。 次に作業ディレクトリにて、.github/workflows/main.ymlを以下のような内容で作成します。今回は「tech*」というtagのpushイベントをトリガーにECRにデプロイされるように設定しました。 name : Push and Deploy ECR on : push : tags : - tech* jobs : build-and-push : runs-on : ubuntu-18.04 timeout-minutes : 300 steps : - uses : actions/checkout@v1 - name : Configure AWS Credentials uses : aws-actions/configure-aws-credentials@v1 with : aws-access-key-id : ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key : ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region : ap-northeast-1 - name : Push to Amazon ECR id : login-ecr uses : aws-actions/amazon-ecr-login@v1 - name : Build, tag, and push image to Amazon ECR env : ECR_REGISTRY : ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY : ${{ secrets.AWS_ECR_REPO_NAME }} run : | IMAGE_TAG=$(echo ${{ github.ref }} | sed -e &quot;s#refs/tags/##g&quot; ) docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG tagをつけてGithubにpushしてみます。するとGithubでBuildされ、AWSへのデプロイが開始します。 Github ActionによりAWS ECRへデプロイ ECRへイメージがデプロイされていることがわかります ECRにあるリポジトリのイメージURLをECSでタスク定義として設定し、クラスターで作成したサービス内でタスクを実行してみます。 デプロイされたECRのイメージを設定 ここでFargateを選択 ECRにデプロイされたイメージを、自動でECSコンテナとして起動させるためには、別でさらにAWS CodePipelineを使用して設定する必要があります。今回はそちらは省かせていただきます。 起動したタスクで発行されたパブリックIPにアクセスすると、無事Github Actionsのイベントでpushされたimageのコンテナを確認することができました。 比較 ここまで技術調査・検証をしてきましたが、クラウドサービスにはたくさんのリソースがある中で、結局、どのリソースを使えば良いのでしょうか? 私の感想としては、今回検証に使用したような非常に簡単なアプリケーション構成で「とりあえずコンテナのデプロイやCI/CDを試してみたい!」というニーズにはCloud Runが適しているなと思いました。 しかし、本来の開発のような、複数のアプリケーションが連携しているシステムで詳細な設定がしたい場合は、FargateやEC2が選択できるECSも選択肢になると感じました。 また、この疑問に対して、GCPの公式の記事では、以下の図でまとめています。 参考: https://cloud.google.com/blog/ja/topics/developers-practitioners/where-should-i-run-my-stuff-choosing-google-cloud-compute-option 管理工数の少ないコンテナの場合、インフラへの考慮を最小限にとどめることで属人化を防ぎ、エンジニアはアプリケーション開発に集中できる利点により、モダンな開発に浸透している理由がわかります。しかし、サーバーレスで管理の少ない環境であるばあるほど、利用料金は高くなり、カスタマイズの幅が小さくなるといったトレードオフの関係にあります。 開発現場における多種多様なシステム・開発体制に対して、それぞれのケースに適切なリソースを選定するためには、保守運用の人的コストや可用性・移植性等の品質面、また料金面などの複数の観点を考慮し、チームで意見を交え、リソースを選定していく必要があります。 最後に 今回は、新卒3ヶ月目の私が、先日のテックフェスで行われたテックトークの内容を現在の開発に活かせないか考え、クラウドサービスのコンテナリソースの調査・検証してみました! 私も今回の調査・検証を経て、クラウドサービスのコンテナリソースやCI/CD・マイクロサービス化についての理解が深まりました。 今後、開発環境をクラウドでの運用に切り替えるタイミングが来れば、積極的に手をあげて環境構築に携わりたいなと思います。 このようにレバレジーズでは年齢や経験に寄らずに、様々な開発の知識・経験が吸収できる環境があります。 若いうちからスキルをつけたい方や、熱意があるチームで毎日学びのあるチーム開発を行いたい方にはこれ以上ない環境だと思います。 ぜひ、みなさんも一緒にサービスを作っていきませんか? レバレジーズに少しでも興味を持っていただけた方は、是非下記リンクを覗いてみてください! leverages.jp *1 : 営業業務の効率化を図り、売上や利益の向上を実現する営業支援システム、Sales Force Automationの略称 *2 : Amazon Web Services、AWS、Powered by AWS ロゴ、および資料で使用されるその他の AWS 商標は、Amazon.com, Inc. またはその関連会社の商標です *3 : DockerおよびDockerのロゴは、米国およびその他の国におけるDocker、Inc.の商標または登録商標です。
はじめに みなさんこんにちは!現在、内定者インターン中の内藤です。 本日は、先日レバレジーズで行われ予想以上に(失礼)大盛況だった「第一回テックフェスのテックバトル」についてお話しします。 この記事を通して、レバレジーズのエンジニア組織の風通しの良さを少しでも感じてもらえると嬉しいです! そもそもテックフェスとは? テックフェスとは何なのか簡単にお話しします。 テックフェスは、レバレジーズのエンジニア組織メンバー全員を対象としたテクノロジーの祭典です。弊社エンジニアの技術力を向上させ、よりよいサービスを世の中に提供できるようにするために企画されました。 今回、テックフェスは記念すべき第一回を迎えました🎊 その記念すべき第一回目は、以下のようなタイムスケジュールで行われました。 テックバトル テックバトルでは、3人1組チームに分かれ、コーディングの「正確さ」と「速さ」を競うコーディングバトルを行いました。 コーディングバトルってやっぱりエンジニアならではで楽しいですよね! では、早速今回のテックバトルで扱った問題を見ていきたいと思います! 問題概要 今回のテックバトルの問題は、以下の3つの実装を各人が担当し3人で協力して一つのものを作るという内容でした。 ※ 今回、企業数と候補者数は同じ( )であるとします ※ この安定マッチングとは、企業と候補者、共に現在組んでいるペアよりも希望順位が高いペア(以降では、ブロッキングペアと呼びます)が存在しない状態を指します テックバトルの問題に挑戦! 上記でも少し触れた通り、この問題の肝はCさんであり、Cさんが如何にこのプログラムを実装するかにチームの命運がかかってます笑 今回、企業(候補者)数 に対して、全組み合わせを力任せで調べるBrute-Forceアルゴリズムを用いると計算量は になります。 が小さい値の時はまだマシですが、 ともなると計算量があまりに膨大になってしまうので、このような時はアルゴリズムの知識が重要になりますね! 今回の問題で活躍するのは、Gale-Shapleyアルゴリズムというもので、このアルゴリズムを用いると計算量は で済ませる事が出来ます。恐ろしいほど強力ですね!😲 Gale-Shapleyアルゴリズム 今回の問題で重要なのは、如何にしてブロッキングペアの存在しない安定マッチングを実現するかという点です。 Gale-Shapleyアルゴリズムは以下のようにして、安定マッチングを実現します。 では、アルゴリズムを確認したところで、実際にコードを書いてみましょう! 実際に書いてみた! 今回は、Aさん, Bさんの実装は置いておいて(ごめんなさい笑)、CさんのGale-Shapleyアルゴリズムに該当する部分のプログラムをpythonで書いてみました。 (pythonにしたのは、自分が日頃業務とは別に使うことが多いからです) 今回使う変数は以下の7つです。 # 初期に与えられている変数 n : 企業(候補者)数 ... 今回、企業数と候補者数は同じ数とします corps : 企業のリスト ... [ 'A' , 'B' , 'C' , 'D' , 'E' ] のような形 candidates : 候補者のリスト ... [ 'a' , 'b' , 'c' , 'd' , 'e' ] のような形 corp_desire_order : 企業の希望順位リスト ... { 'A' : [ 'b' , 'e' , 'd' , 'c' , 'a' ], 'B' : [ 'c' , 'a' , 'e' , 'd' , 'b' ],}...のような形 candidate_desire_order : 候補者の希望順位リスト ... { 'a' : [ 'B' , 'D' , 'A' , 'E' , 'C' ], 'b' : [ 'C' , 'A' , 'D' , 'E' , 'B' ], ...}のような形 # 最終的に出力する変数 corp_to_candidate : 企業と候補者のマッチングリスト ... { 'a' : 'D' , 'b' : 'C' , ...}のような形 candidate_to_corp : 候補者と企業のマッチングリスト ... { 'D' : 'a' , 'C' : 'b' , ...}のような形 この7つの変数を基に、プログラムを書くと以下のようになります。 def gale_shapley (corps, candidates, corp_desire_order, candidate_desire_order, corp_to_candidate, candidate_to_corp): # 1. 候補者とマッチングしてない企業1社が、希望順位が一番高い候補者にマッチングを申し込む for corp in corps: if corp not in corp_to_candidate: free_corp = corp candidate = corp_desire_order[free_corp][ 0 ] # 2-B. 1で選ばれた候補者がマッチング中であった場合、現在マッチング中の企業とマッチングを申し込まれた企業の希望順位を比較する if candidate in candidate_to_corp: another_corp = candidate_to_corp[candidate] # 2-B-b. もし、マッチングを申し込まれた企業の方が希望順位が上なら、候補者は現在のマッチングを解消し、マッチングを申し込まれた企業とマッチングをする if candidate_desire_order[candidate].index(free_corp) &lt; candidate_desire_order[candidate].index(another_corp): del corp_to_candidate[another_corp] corp_to_candidate[free_corp] = candidate candidate_to_corp[candidate] = free_corp corp_desire_order[another_corp].pop( 0 ) # 2-B-a. もし、現在マッチング中の企業の方が希望順位が上なら、候補者はマッチングを申し込まれた企業を断る else : corp_desire_order[free_corp].pop( 0 ) # 2-A. 1で選ばれた候補者がマッチングしていなかった場合、候補者は企業からの申し込みを承諾する else : corp_to_candidate[free_corp] = candidate candidate_to_corp[candidate] = free_corp # 3. 1~2を全ての企業と候補者がマッチングするまで繰り返す while len (corp_to_candidate) &lt; n: gale_shapley(corps, candidates, corp_desire_order, candidate_desire_order, corp_to_candidate, candidate_to_corp) 書いてみると意外とシンプルですよね。 皆さんも是非他の言語でチャレンジしてみてください! 最後に 今回、テックバトルを通して、「新しいアルゴリズムを知れたから良かった!」というよりは、エンジニア同士の交流が何よりも貴重で楽しかったです笑 レバレジーズのエンジニア組織は、技術力だけではなく、今回のイベントのようなエンジニア同士の交流も大事にする組織です。日頃からこのようなイベントを行うことで部署間の壁がなく、気軽に悩みを相談出来る環境が常に整っており、とても働きやすいと日々感じています! 皆さんもこんなレバレジーズで一緒に働いてみませんか? レバレジーズに少しでも興味を持っていただけた方は、是非下記リンクを覗いてみてください! leverages.jp
はじめに こんにちは。21年新卒の益子です。現在私は、看護業界に特化した人材サービス「看護のお仕事」において、求人サイトのUX改善やそれに伴うシステム開発、ディレクションに携わっています。 看護のお仕事では、サイト改善において指標となる定量データだけでなく、「看護師さんにとっての使いやすさ」などの定性データも重視して改善を進めています。 このようなサイト改善の取り組みに対しては、事業部としても知見が浅く、常に手探りの状況でしたが、私はユーザビリティテスト・デプスインタビューなどのUXリサーチやその結果を元にした改善施策の立案・実装・効果検証までを一貫して担当し、1年間でCVR(※1)を13%改善しました。 (※1). Webサイトへのアクセス数、またはアクセスしたユーザー数のうち、コンバージョンに至った割合を示す指標。転職エージェントサービスが運営する求人サイトの場合、求人応募やサービス登録に至ったユーザーの割合を示す。 この記事ではエンジニアとして新卒入社してからUX改善に取り組み始めたきっかけ、取り組みの中でぶつかった壁、どのように問題を解決しCVR改善という成果に結び付けたかについてまとめたいと思います。 きっかけは「ユーザーが迷いながら求人を探している」現場に遭遇したこと 私が求人サイトのUX改善に取り組み始めたきっかけは、ユーザビリティテストに同席した際に希望する求人を探せずに戸惑っているユーザーの様子を目の当たりにしたことでした。 ユーザビリティテストでは被験者に対し「東京都渋谷区で、希望する条件に一致する求人を検索して表示して下さい」といったタスクをお願いし、モデレーターの目の前でタスクを実行してもらいます。被験者の実際の操作を通して、スムーズに求人を検索できるか、希望条件に一致する求人を絞り込めるかなど、ターゲットユーザーにとってのプロダクトやサイトの使いやすさを評価していきます。 初めて同席したユーザビリティテストで見たものは想像と大きく異なり、「希望条件に対して、サイト上のどの絞り込み項目が適切なのか迷っている」「絞り込み条件を増やしすぎてしまい希望する求人を見つけることができない」というユーザーの様子でした。 私はこの体験を通し「どれだけ努力して実装してもユーザーにとって使いにくいようでは無意味。看護師さんにとって使いやすい求人サイトを作るために開発業務以外もできるようになりたい。」と思うようになりました。 求人サイトのUX改善を進めるため、自ら提案して3つの取り組みを開始 ユーザーにとって使いやすいプロダクトを作るためには、改善施策の全ての議論をユーザー起点で行う必要があると考えました。さらに具体的に言うと、サイト改善に関わるメンバー全員が、架空のユーザー像を元に議論し合うのではなく、実際にユーザーに触れて得た一次情報を元に議論できる状態になる必要があると考えました。 ユーザーの一次情報から議論を行えるチームになるため、以下の3点を提案し、チームで実行しました。 1. デプスインタビューを実施し、看護師転職において求人サイトがどのように使われているのかを調査する まず看護師転職の課題について解像度を上げるためにデプスインタビューを行いました。 デプスインタビューとは、特定テーマについて対象者と1対1でインタビューを行う定性調査手法です。対象者の表面的な行動・思考だけではなく、行動・思考の背景にある事情や、対象者ですら言語化できていない課題を明らかにすることができます。 デプスインタビューでは、調査したいテーマに合わせた対象者の選定が調査の成果を左右します。私たちは調査結果を求人サイトの改善施策に落とし込みたかったため、「転職検討中であるが、転職エージェントサービスの利用経験がない人」を対象とし、5名に対し調査を実施しました。 転職エージェントサービスの利用経験がある人は、求人サイトの使いやすさよりも、過去の利用体験を検討材料としてサービスを判断する可能性があります。そのため、転職エージェントサービスの利用経験がない人にインタビューを実施することで、転職を通して得たいものに対し、どのように求人サイトを利用するのか・転職サービスに何を期待するのかを明らかにしようと試みました。 2. オープンタスクでユーザビリティテストを行い、現状のサイトを再評価する デプスインタビューを通して得られた示唆は大きく2点でした。1点目は転職を通して得たいものやその理由、2点目は求人サイトを「単純な求人検索ツールとして使用するのではなく、その求人サイトを運営する転職エージェントサービスを比較するために使用している」という実態です。 その示唆に対し、オープンタスクでユーザビリティテストを行い現状のサイトを再評価しました。単純にタスクをお願いして実行してもらうのではなく、「このページではどのような情報が得られましたか?」「次はどのように操作したいと思いますか」「サイトを離れた後は何をしたいと思いますか?」というオープンタスクでサイトの評価を行いました。 これにより「なぜページ内のコンテンツが読み飛ばされているのか」「なぜ特定のボタンが頻繁にクリックされるのか」など、なぜユーザーはこのように操作するのかというところまで定性データを収集することができました。 3. 改善施策の起点となる仮説を、ユーザーを起点にして言語化する デプスインタビューやユーザビリティテストでユーザーに対する解像度が高まり、チーム内で改善施策の議論が活発になりました。 その議論を”ユーザー起点”という観点で質のよいものにするため、「タスク管理ツールに改善施策案を起票する際、ユーザーを起点に仮説を言語化する」というルールを作り、徹底しました。 具体例を挙げると「求人応募ページの応募ボタン文言が〇〇であることにより、ユーザーは違和感を覚えて求人応募を完了することができないのではないか」という起票内容です。 このルールを履行することで、改善施策においてユーザーを軸に仮説を立てることが仕組み化され、常にユーザーの一次情報を材料とした議論を行うことをメンバー全員に定着させることができました。 施策リリース後にぶつかった「ABテストで悪化傾向が出た時に仮説が途切れてしまう」という問題 前述の3点の取り組みにより、サイト改善における議論の質が高まりました。しかし、いくら議論を重ねても、すべての施策で改善傾向が出るとは限りません。 実際に、チームメンバー全員で考え抜いた施策をリリースした際、CVRの悪化傾向が出てしまったことがありました。 サイト全体のコンバージョンへのインパクトが大きい求人詳細ページのUIを大幅に変える施策を打ち出した時のことです。プロトタイピングでのユーザビリティテストを何度も実施しながら施策内容のブラッシュアップを重ねていたものの、実際に施策をリリースしてABテストを実施したところ、CVRが大きく悪化する結果が出てしまいました。 インタビューやユーザビリティテストを重ねながらメンバー全員で考え抜いて打ち出した施策だったため、各数値指標の悪化を目の当たりにした時には、次の打ち手を見失う状況に陥りました。 問題を解決するために、リリース直後から定量・定性両方のデータで施策を振り返る この状況に陥った根本的な原因は、定性データから打ち出された施策効果を検証する仕組みが整っていなかったことでした。 Aパターン・BパターンそれぞれのCVRの結果や各ページの遷移率などの定量的な施策効果の検証に関しては以前から仕組み化されていましたが、「施策内容によってユーザーの操作はどのように変化したか」「なぜ変化したか」といった定性的な施策効果を検証する仕組みはなく、1から構築する必要がありました。 私はこの状況を改善するため、リリースした施策を定量・定性両方のデータで検証することを提案し、チームメンバーで実行に移しました。UI上、施策の要点となるすべての箇所にクリックログを実装してクリック率やその後のコンバージョンを計測しながら、ユーザビリティテストも並行して実施し、操作の背景にあるユーザーの心理を探るという検証方法です。 これにより施策の仮説が途切れることがなくなり、1つの仮説から後続施策・新しい仮説が生まれる状態を作ることができました。 前述した求人詳細ページのUIを大幅に変える施策も、定量・定性両方のデータで検証することで、求人応募ページへ遷移するボタンが致命的な悪化要因となっていたことを発見することができました。その後の追加施策でボタン部分を改修し、98%以上の有意差でマイクロコンバージョンの完了率を29%改善することができ、CVRを向上させることができました。 結果、どのような変化があったか これらの取り組みを継続した結果、ユーザーを起点とした施策立案・施策のリリース・定量と定性の両方を駆使した効果検証・追加のブラッシュアップ施策の実行まで、UX改善のPDCAサイクルを回すことができるようになりました。 その変化は数値成果に表れ、1年間で最重要指標であるCVRを13%改善することができました。 また、UX改善のPDCAサイクルは途切れず、施策の確度も向上しています。施策立案においてはこれまでに実施していなかった新しい定性調査手法やヒューリスティック評価を取り入れ、施策実行においては開発メンバーが着手優先度を柔軟に調整することでABテストの実行数が増加しています。 サイト改善に関わるメンバー全員が、前年度以上の成果を出そうとモチベーション高く取り組んでいます。 終わりに 昨年度一年間の取り組みは、自分自身の今後にも大きな変化を与えたと思っています。 開発職として入社したものの、「ユーザーが迷いながら操作している」という現場に遭遇したことで、それまで実務で取り組んだことがなかった「UX」という分野に取り組むきっかけができました。 また開発業務の枠を超えて、リサーチ・施策立案・施策の実装とリリース・効果検証の全てのプロセスで提案と実行を行う中で、レバレジーズが顧客体験の改善という観点で優れているところ・未熟なところの両方が見えてきました。 上記の経験を通し「エンジニアでありながらマーケティング分野にも職域を広げたい」という思いが「レバレジーズの顧客体験の改善を自分が牽引できるようになりたい」という思いに変わりました。それを実現するために業務内容も少しずつ変わり、昨年度はマーケターが持っていた「UX改善によるCVRの向上」というミッションが、今年度からは自分のミッションとして与えられました。 レバレジーズには社員一人ひとりが事業や担当業務の課題を見つけ、新しい取り組みを提案する・責任をもって実行するという行動を評価する文化があります。そこには年次はもちろん、職種の枠も関係なく、その文化があったからこそ自分の目標に近づくための働き方ができていると感じています。 レバレジーズには事業やプロダクトの成長に、開発の職域を超えて取り組んでいるエンジニアが多く在籍しています。開発職として技術を磨きつつ、事業やプロダクトの成長に幅広く関わっていきたいという方は、カジュアル面談などでレバレジーズのエンジニアと話してみませんか?きっと面白い発見があると思います。 ご興味がある方は是非こちらからエントリーをお願いします! leverages.jp
はじめに この記事では、未経験から短期間でリーダーになるためのマインドセットをご紹介したいと思います。 僕のように未経験でエンジニアになる方、リーダー経験を若いうちに積みたい方には、ぜひ読んでいただきたいなと思っております! 自己紹介 初めまして、エンジニアの五十嵐です。 僕は、2020年10月からレバレジーズで内定者インターンを開始し、2021年4月に入社してから現在に至るまでHRテック事業部という新規事業の部署に所属し、SaaSの開発を行っております。 学生時代は、小、中、高、大学とサッカーに青春を費やしてきたスポーツ少年でした。 大学では、情報系や理系の学部に所属していた訳ではなく、文系の商学部に所属していたので、Webエンジニアとは無縁の学生時代を過ごしておりました。 入社前は、自分で家計簿アプリやTwitterの模倣アプリ、シフト管理アプリなどを作ったり、Paizaなどでアルゴリズムを考える問題を解いたりしていた程度でした。 なぜエンジニアに? きっかけは就職活動でした。 様々な業界の仕事や業種、企業を知りたかったので、金融系や日経大手の企業など幅広く受けていました。 多くの企業や業界を見ている中で、ITのWeb業界に興味をもち、様々な人と会って話を聞いていくうちにエンジニアという道もあることを知りました。 そして、エンジニアの働き方や仕事内容などに興味をもち、実際に自分でプログラミングを学び始めたのがきっかけでした。 新規事業に参画することに 僕は、新しい物事や常に変化する状況に対してモチベーションを高く持って行動するタイプです。レバレジーズ選考時にその旨を会社側に伝えていました。 内定後は、すぐにでも実務の経験を積みたいと考え、内定者インターンの希望を会社に出していました。 内定者インターンをするに当たってどの部署で働きたいかを人事と面談している時に、新規事業が動き出している話を聞き、やってみないか?という提案をもらいました。 僕は、裁量権があり、幅広い経験ができる会社だと思ったからこそレバレジーズの内定を承諾しましたが、まさかこんなにすぐにチャンスがくるとは思わず驚きました。 こんなチャンスはもしかしたら2度と来ないかもしれないと思い、新規事業でインターンをしたい!と伝え、参画することになりました。 業務内容 業務として経験したことは以下になります 要件定義 基本設計・詳細設計 実装 テスト UI/UXに対する提案 使用技術は以下になります FE TypeScript React Apollo Client BE TypeScript PHP Laravel Lighthouse NodeJS NestJS GraphQL Apollo Federation PostgreSQL Docker Redis Infra GCP Firebase external-service SendGrid Auth0 0からの開発かつ、初期メンバーは5人だったので、インターン生であっても上流工程から下流工程まで経験させてもらいました。 バックエンドを主に担当し、GraphQLでAPIの開発をマイクロサービスアーキテクチャで行っていました。 設計や開発を行う中で、未経験の僕が意識したことが以下の3つになります。 チャレンジしてどんどんアウトプットを出す メンバーからの信頼を得る サービスを俯瞰した提案を心がける 1つずつ順を追って説明していきます。 チャレンジしてどんどんアウトプットを出す 当初はGraphQLを知らず、SQLの仲間かなとか思っていましたし、DB設計の知識も全くありませんでした。 そんな僕ではありますが、個人的に決めていたことがありました。 それが、「わからないことを、すぐに質問をしないこと」です。 僕の先輩が営業からエンジニアにジョブチェンジした方で、「とりあえずチャレンジしよ!」というポジティブでレバレジーズっぽい方でした。 それに習って、僕もわからないことがあった場合でも、上司やメンバー、業務委託の方の設計書やコードをみて学んで、わからないことは調べて、自分なりのアウトプットを出していました。 アウトプットを出すことで自分の考えも整理でき、レビューで指摘された場合でも意見を伝えることができていました。 このやり方は、インターン開始当初はタスク消化の遅延を出してましたが、その時に吸収されたものが数ヶ月後になって活きてくるようになり、徐々にスピード感を持って業務を行えるようになっていきました。 なので、このブログを読んでくださっている新人エンジニアの方はわからない技術や物事は自分で学んでどんな形であってもアウトプットを出すことをオススメします。 メンバーからの信頼を得る 先輩と1on1を行っている時、何か個人的な目標を作ると良いとアドバイスをもらい、1年間の目標として「困った時は五十嵐」という立場になることを設定していました。 そして、その状態はどんな状態を指すのか考えた時に、誰よりもサービス全体の理解と使用技術の理解をしている状態だと考えました。 そのために、僕は任されているタスクを完璧にこなすことはもちろんですが、業務以外の空いた時間や休日などを利用して以下のことを自主的にキャッチアップしていました。 サービスに関わるマイクロサービス8つ全ての仕様理解とデータ構造の把握 クリーンアーキテクチャの理解やそれに伴うSOLID原則の理解 GraphQLやNestJSなどのモダンな技術の理解のために個人的にアプリを作成 これらのことは、「困った時は五十嵐」という立場になるためにやっていたことではありますが、新しい物事を学ぶことが楽しくて、つい時間を忘れてしまうこともしばしばありました。 特にアーキテクチャ周りの勉強をする前と後ではサービスの見え方が変わりましたし、改善点も見えてきたのでスキルアップしたなという実感ができました。 サービスを俯瞰した提案を心がける 僕がレバレジーズを選んだ理由の一つが、早い段階でリーダー業務を経験したかったことが挙げられます。 そのため、与えられた自分のタスクだけこなすのではなく、主体的に行動し関係する影響範囲を広げることで、自らのスキル向上とリーダーからの信頼獲得を意識していました。そしてその後、少しずつリーダー業務を任されるようになっていきました。 具体的には以下の4点です メンバーのサポート タスクの作成、優先度付け リリーススコープを考慮した意思決定 スクラムMTGのファシリテート この中でも、「リリーススコープを考慮した意思決定」の部分は早めに吸収してよかった点だったと思っています。 なぜなら、メンバーの僕が見えている部分とリーダーから見えている部分が違うことがわかり、よりサービス全体が見れるようになったからです。 リーダーは、より高い位置から俯瞰してサービス全体をみており、広い範囲の仕様を把握をして、様々な情報や観点から意思決定をしていました。 なので、僕もリーダーに対して提案を行う時は、担当機能の仕様把握だけでなく、サービス全体の仕様とその影響範囲の把握をしてから提案をするようにしていきました。 より俯瞰してサービス全体を考えた提案をすることでリーダーから信頼を得て、リリーススコープ内の仕様の選定や機能実装の際の意思決定を少しずつ任されるようになっていきました。 読んでよかった本 設計や実装時のわからない時にインプットのために読んでいた本がいくつかあるので紹介します。 リーダブルコード 単にコードを書くだけではなく、わかりやすく、そしてメンテナンス性の高いコードを書くために、気を付けたいことなどが書かれています。 実践的なテクニックや心構えが中心で、例えば「どんな名前付けをした方が良いか」ということ等が紹介されています。 現場で役立つシステム設計の原則 わかりやすいソースコード、設計についてオブジェクト指向設計の特徴を生かしながら具体的に説明してくれる本となっています。 実務で役立つような内容となっており、設計について完全に初心者でも、具体的な説明で設計に何が必要なのかを理解できます。 アジャイルソフトウェア開発の奥義 ソフトウェア開発の原則のSOLID原則などの設計についてやデザインパターンとケーススタディのコードが書いてあります。 新人エンジニアには少々難しい内容ではありますが、知っているのとそうでないのではスキルアップに大きく差が出ると思うのでおすすめです。 Clean Architecture この本は、いわゆる設計手法としての「クリーンアーキテクチャ」についての話かと思いがちですが、 内容はアーキテクチャとは何か、どういった目的意識があるのかというアーキテクチャ全体の概念で、非常にためになる話が多くいくつも学びがありました。 終わりに これらのことを意識して、ひたむきに業務やサービスに向き合っていたことで、入社2年目となる現在はリーダーを任せてもらっています。 リーダーになってからは、開発業務以外に、チームのタスクの作成や優先度付け、上層部へのエスカレーション、メンバーのピープルマネジメント等も行っています。 マネジメントの難しさに悩むことも多々ありますが、関連書籍で学んだり、上長などに相談するなどして自分の業務の幅を広げています。 僕の経験でもわかるように、レバレジーズは経験や年齢で判断せずにチャンスを与えてくれる環境です。 若いうちから様々なチャレンジをしたい方にはこれ以上ない環境だと思います。 また、未経験で不安があった僕ですが、今では毎日学びの連続で、責任感はありつつも非常に楽しく働くことができています。 ぜひ、みなさんも一緒にサービスを作っていきませんか? レバレジーズにご興味ある方は、こちらからエントリーお願いします! leverages.jp
はじめに こんにちは。2021年新卒の田中です。現在私は、フリーター・既卒・第二新卒など、20代を対象とした就職・転職支援サービス「 ハタラクティブ 」のシステム開発やユーザー体験の改善に携わっています。 ハタラクティブでは、Laravel + jQueryだったフロントエンド環境をReact/TypeScript + Next.jsにリプレイスしました。その際、コンポーネントシステムとしてAtomic Designを採用し、入社1年目ながら選定から設計、導入プロジェクトの管理、運用ルールの構築を任せていただきました。 この記事では、ハタラクティブでどのような課題を抱えていてAtomic Designを採用したのか、導入を進めるにあたってどのような問題が発生し、どう解決したのか、メリットを含めてご紹介します。 課題は開発コストの削減とデザインの統一 ハタラクティブではフロントエンドをReact/Next.jsで開発していますが、当初はコンポーネント管理に関するルールが決まっていない状態でした。そのため、同じ記述が複数箇所にあったり、同じUIパーツが共通化されていなかったり、無駄な記述や重複が多く、開発コストが余計に増えたり保守性が下がる問題や、修正作業が属人化する問題を抱えていました。 当時は、スタイルガイドが機能していなかったため、チーム内でデザインについての共通認識を取れず、作成したUIパーツを使い回す体制も整っていませんでした。その結果、同じUIでもページによってデザインが異なっている状態が発生していました。 以上のことから、ハタラクティブオウンドメディアチームでは開発コストを小さく抑え、デザインが統一された状態を作る課題があったため、導入事例やドキュメントが多いAtomic Designを採用し、コンポーネントシステムを構築することを提案しました。 導入に際しての懸念 チーム内の知見が少なかった 当時は本格的にAtomic Designを使った開発経験があるメンバーがいませんでした。どのように導入すればいいかのイメージがすぐに湧かず、チーム内でコンポーネント化の共通認識が持てない状況でした。 そのため、私が主導して他サービスのAtomic Design導入事例の記事や社内の他チームへのヒアリングを通じて、Atomic Designに関する知見を集めました。自分で試しにテンプレートファイルのAtomic Design化を実装し、そこで得られた知見に基づき、チームで導入の方針や定義、懸念点の解消方法を明確にするための話し合いの場を複数回設けました。 導入コストが大きかった コンポーネント化を進めることで今までのStyleの記述方法を大きく変える必要があるなど、ハタラクティブへのAtomic Design導入コストの大きさの懸念もありました。 導入コストに関しては、既存コードの整理や将来的な開発コストの削減を目指す方針で認識を合わせ、通常施策とは別のリファクタリングタスクを通じて長期的に進めていきました。Styleに関してはNext.jsでサポートされているCSS Modulesを採用しました。CSS ModulesはCSSをmoduleに分け、JSファイルでimportすることでスタイルを適用する手法です。コンポーネントごとにmoduleファイルを分けることでスタイルをコンポーネントに閉じることができ、class名の干渉を考慮する必要なく記述することができます。 /* Example.module.scss */ .title { font-size : 1.6rem ; color : #222 ; } .box { padding : 8px 16px ; background : #eee ; } // Example.tsx import styles from &quot;./Example.module.scss&quot; const Example: React.FC = () =&gt; { return ( &lt;&gt; &lt; h2 className = { styles.title } &gt; タイトル &lt; /h2 &gt; &lt; div className = { styles.box } &gt; { /* 中にコンテンツ */ } &lt; /div &gt; &lt; / &gt; ) } export default Sample CSS Modulesの導入から記述方法の共有を主導し、デザイナーがコンポーネント単位の書き換えを徐々に進めていくやり方を取りました。 方針とルールを決めて、長期的に導入を進めていく 基本を理解した上でコンポーネント分けルールを作る Atomic Designの基本を理解し、ハタラクティブに導入する上での解釈をすり合わせ、コンポーネント分けルールを作成しました。ルールの一部を紹介します。 例えば、MoleculesはAtomsやOrganismsと異なり定義が難しかったため、再利用性を判断軸に置き、分類に迷った場合は一旦Organismsにする運用方針に決めました。 データの取得や保持に関しては、統一性と再利用性を高める目的で、API通信に関する機能や各ページで異なるデータはPagesのレイヤーに、固有のデータはOrganismsに持つようにしました。 準備→試験導入→実装の順にスケジューリング 方針とルールが固まったら、どういったスケジュールで進めるのかを決めました。実際のプロジェクトは準備編、試験導入編、実装編の3つのフェーズに分けて進めました。 準備編では「何をやるのか」「どこまでやるのか」が決まっている状態をゴールに据え、既存デザインパターンの洗い出しとコンポーネント設計を行いました。デザイナーと一緒に既存パーツのバリエーションを精査しページ全体のトンマナやデザインを統一しながら、コンポーネント分けルールに沿って分類と設計を進めました。 試験導入編は「チーム全員がやることを理解し、導入方針に納得している状態」をゴールとし、本格的な実装の前に工数を取って全員がAtomic Designを試す週を設けました。複数の視点から見えてくる問題や疑問を事前に解消し、スムーズに実装を進めることを目的としていました。 実装編は「通常施策への影響を小さく抑えながら導入を完了する」ことがゴールで、Atoms→Molecules→Organismsと小さいパーツから順に実装していきました。 スタイルガイドが機能していない問題に対しては、storybookの導入を決めました。 storybook.js.org storybookは、UIコンポーネントの管理やアドオンを使ったテストなどができるツールで、コンポーネント化と並行してスタイルガイド作成を同時に進めることにしました。 こうして約半年にわたるスケジュールを作成し、導入プロジェクトを進めていきました。 実装時にぶつかった問題 準備を重ねて実装編に入りましたが、いくつか問題があがってきました。 Atoms, Molecules, Organismsだけでの管理ができないケースがある Atoms、 Molecules、 Organismsだけでの管理は、ディレクトリを増やさずに済むのでシンプルさを維持できたり、世の中にリファレンスが多いメリットがあります。しかし、エントリーフォームなど複雑なUIに対しては「Atomsは最小単位で他のコンポーネントを含まない、MoleculesはAtomsを1つ以上含む」などのルールが適用できず、例外が発生してしまうのが避けられない状況でした。 それに対し、ハタラクティブ独自のディレクトリルールを追加するのは、慎重に考える必要がある一方で、ハタラクティブのデザインに合わせて管理しやすい形を実現できるメリットがあります。 そのため、既存のAtomic Designの形を大きく変えないように注意しながら独自のディレクトリルールを作成しました。具体的には、Layouts, Transitions, PageContentsディレクトリなどを追加し、それぞれレイアウトを共通化するコンポーネント、カルーセルやアコーディオンなどのインタラクションを共通化するコンポーネント、ページ全体のStyleやStateに関するコンポーネントを管理するようにしました。 コンポーネントに関する作業が属人化している 導入プロジェクトが始まってしばらく経ったタイミングで作業の属人化が問題となり、2つの問題に分割して対処しました。 1つ目は基準が明確化されていないことです。準備編で作成したルールを見直し、Atomic Designやハタラクティブのデザインパターンに関する知識が少ない人でも理解できるように、コンポーネントの各ディレクトリの役割や実際に分ける際のフローをドキュメント化し共有しました。 2つ目は、慣れの問題です。プロジェクトの進捗を優先していたことで特定の人にAtomic Designタスクが集まっている状態になっていました。限られた人しか出来なければ導入後の維持が困難になります。そのため、なるべく全員が実装タスクを担当するようにし、私がレビューに入るようにしました。 導入した結果 デザインの統一、管理が容易に 導入の過程でデザインを精査したことでデザインの統一が実現し、共通コンポーネントを使うことで微妙に違うデザインが生まれることが防げるようになりました。 storybookを使った管理をすることでデザイナー・エンジニア間のUIパーツの認識合わせや、どんなパーツがあるかの把握が容易になりました。 開発速度の向上と属人化の解消 コンポーネントが共通化されたことにより、変更を加える際の対応箇所が1箇所、ないしは少なくなって開発速度が向上しました。 それまでは修正漏れを避けるために担当者が固定されて作業が属人化していましたが、ファイルが統一されたことで誰でも修正を担当できるようになりました。 大規模なデザイン変更や新規コンテンツ実装にも力を発揮 Atomic Designの導入がほとんど終わってきた段階で、サービスのブランドコンセプト見直しに伴う大規模なデザイン変更がありました。その際、コンポーネント化によって新デザインの反映がスムーズになったり、ルールが作成されていたことで新規UI追加の際も深く悩むことなく実装ができました。 新規コンテンツとして求人検索ページの実装タスクでも力を発揮しました。 求人検索ページ はボタンなどの既存パーツを使いつつ、検索パネルなど新規のUIも多いコンテンツでしたが、storybookやコンポーネントルールにより作業効率を高めることができたことで2週間という短い時間での実装を可能にしました。 終わりに Atomic Designの採用によって、開発コストの削減とデザインの統一という課題を解決し、オウンドメディアとして今後の成長を加速させていく基盤を作ることができました。しかし、Atomic Designを導入さえすれば解決できるというものではありません。職種を超えて課題について議論し、常により良い方法がないかを模索できるチームがあったからこそ実現できたと思っています。 配属から2ヶ月足らず、Reactに対する経験も浅い状態で、今回のAtomic Design採用提案から導入まで担当させていただきました。このように、レバレジーズには年次や経験を問わず、主体的に取り組める環境が整っています。 レバレジーズは「主体的に仕事に取り組みたい」「エンジニアの技術を活かしてサービスの課題を解決したい」と思っている方が活躍できる環境です。 ご興味を持たれた方は下記リンクからご応募ください。 leverages.jp
はじめに こんにちは、レバレジーズ フリーランスHub開発チームです。 現在、弊社が運営する フリーランスHub というサービスでは検索エンジンとしてElasticsearchを採用しています。 この記事では、エンジニアがサービスの成長速度と工数を考慮した上で、Elasticsearchを採用するに至った背景や理由、そして、MySQLからElasticsearchへ移行した結果どれくらいパフォーマンスが改善したか、などを紹介していきたいと思います。 サービス紹介 フリーランスHub は全国のフリーランスエージェントの保有案件から、10万件以上の案件をまとめて掲載しているエンジニア・クリエイター向けのフリーランス求人・案件メディアです。 2021年4月にリリースした新規事業で、サイト内でフリーランスエージェントの案件を集約して掲載しており、フリーランスを検討される方が案件に一括で閲覧・応募することができます。 また、サイト内で開発言語や職種別に案件の検索ができることに加え、エージェントの詳細ページから各エージェントの特徴やサービスを受けるメリットなどを比較し自分にあったフリーランス案件・フリーランスエージェントを探すことが可能です。 検索機能の説明 検索パネル は開発スキルや職種、業界、こだわり条件(週3など)や、都道府県、路線、駅での検索機能があります。 サービス設計フェーズ 見通しを立てる 事業をスタートする際は様々な軸で数年の見通しを立てます、今回のテーマに影響するものはこの辺です。 フリーランスHubは機能数が少ないので小さくスタートし、短期間で拡張と機能追加をしてサービスを成長させていく方針にしました。 その他見通しの説明は記事の最後で 補足 しておきます。 アーキテクトの選定 AWSを利用している他プロジェクトの導入実績をベースに選定、Elasticsearchでの全文検索は幾つかの事業チームでの運用実績があります。 RDSは2つあり、メインはAurora(MySQL)、データ収集と分析を低スペックのMySQLにしています。 集まってくれた開発メンバーの中にElasticsearchを熟知しているメンバーがおらず、安定性と開発速度の両面で懸念が出てきました。 検討 リリース前に準備しておきたいことは山積しているため甘い判断は出来ない BigQueryでの全文検索は社内導入実績が無いためElasticsearchよりハードルが高い 最終的にElasticsearchは必要になる 開発期間中に実際のデータが無い状態で、振る舞いを熟知し安定運用レベルに達するのは難しい 5-10万件ならAurora(MySQL)FULLTEXTインデックスでも良さそう RDB利用のほうがLaravelデフォルト機能の親和性が高く開発が楽で初期はメリットが多い AuroraはMroongaの利用が出来ないが、Elasticsearch移行するなら大きく問題は無い 決断 Aurora(MySQL)FULLTEXTインデックスで初期開発を行う 機能・拡大フェーズでElasticsearchへ切り替える際、スピードを損なわないシステム設計を行う システム処理設計 まずはRDSだけでシステムを構築し、切替前にElasticsearchにデータを同期した後、検索機能を切り替える方法を選択しました。 リリース前 (3万件〜) 機能追加・拡大 (9〜12万件) 処理説明 案件データ作成 : HTMLクロール等で収集した元データから表示用のテーブルを作成 全文検索データ作成 : タイトル・内容・スキルなどの文字列を検索用テーブルの1つカラムへ 件数作成 : 表示する事の多い全案件数などはカウントテーブルへ事前に入れておく サイトからの検索処理 全ての検索処理は検索テーブル、表示は表示テーブルと役割を分ける ヒットした検索テーブルからidを取り、表示テーブルをidで検索し表示する 例えば検索パネルで PHPで検索した場合 PHPという文字列で検索テーブルを検索する スキルテーブルからPHPを探しテーブルidで検索というプロセスは行わない 一覧だけでなくサジェスト処理なども同じ 検索データを上手に作成しておく事が必要となる代わりに、検索SQLでテーブルをジョインする必要が無くシンプルになります。 Elasticsearch利用時も検索テーブルだけのシンプルなクエリに出来ます。 インフラ構築 Elasticsearchは最終的なスペックでビルドし利用するまでスペックを落として運用していました。 AWSはCDKで構築したのでサイズとノード数を変更し実行するだけで切り替わる構成です。 Elasticsearchに限らず初期は不要なケースも最初から作成しておきました。例えば、MQはRDSでスタートし後でSQSに切り替えました。 インフラ変更は影響範囲が広く、後で追加を行う時の苦労が多いため、構成は最初から作っておき利用時に起動する等により、サービスの安定性と成長スピードを損なわないメリットがあります。 無駄なコストが発生してしまうので、サービス成長速度を上げ無駄となる期間を短くします。 比較 実行結果 早速結果から、クエリキャッシュOFFで実行しています。 MySQL long_query_time は 0.25s を閾値にして運用しています。 案件数が5万件ほどの時期にソート順の要件が増えてきました。 優先 : 一部の案件を上位に表示 募集終了 : 募集終了した案件も一覧の後ろの方に追加 指定した検索条件で関連順の場合はMySQL FULLTEXTインデックスでも高速ですが、独自にソートを追加したSQLは速度低下が見られました。 ダミーデータで作成した32万件のテーブルにクエリ実行した結果、ヒットした件数が増えるほどソート処理に大きく時間がかかる事がわかりました。 契約数・掲載数も順調に伸びているため、早めにElasticsearchに切り替えることにしました。 1週間ほどエンジニア1名をElasticsearchに貼り付け32万件のダミーデータでクエリを発行し大きく改善する事を確認、他の追加開発を止める事もなく、その後3週間ほどで切り替えが完了。 トークナイザーやデプロイの最適化など、経験と専門性が必要な領域は切り替え後に作り込みました。 クエリ テーブル、インデックスは item_search です。 CREATE文は 補足 に記載しておきます。 Aurora(MySQL) 初期 SELECT `item_id`, `status`, `created_at`, `base_value` FROM `item_searches` WHERE MATCH(search_string) AGAINST( -- キーワードに「東京」が含まれる案件の中で、「Java」もしくは「PHP」の案件の取得 ' +( &quot;&quot;東京&quot;&quot;) +( &quot;&quot;Java PHP&quot;&quot;) ' IN BOOLEAN MODE ); +優先+募集終了 ( SELECT `item_id`, `status`, `created_at`, `base_value` FROM `item_searches` WHERE -- `item_id` = 25000,30000,35000,40000,45000は上位表示する優先データ `item_id` IN ( 25000 , 30000 , 35000 , 40000 , 45000 ) ORDER BY `updated_at` DESC ) UNION ( SELECT `item_id`, `status`, `created_at`, `base_value` FROM `item_searches` WHERE `status` = 1 AND MATCH(search_string) AGAINST( ' +( &quot;東京&quot;) +( &quot;Java PHP&quot;) ' IN BOOLEAN MODE ) ) UNION ALL ( SELECT `item_id`, `status`, `created_at`, `base_value` FROM `item_searches` WHERE `status` = 2 AND MATCH(search_string) AGAINST( ' +( &quot;東京&quot;) +( &quot;Java PHP&quot;) ' IN BOOLEAN MODE ) ); 上位表示する優先データは別のクエリで先に求めています。 上2つのSQLをUNIONしているのは上位表示したデータの重複を防ぐためです。ヒット件数が多い検索条件であるほどソート処理に時間がかかります。なおUNION ALLにしてもヒット件数が多い限りほぼ改善しません。 また、WHERE句でMATCH()を使用すると自動的に関連度順でソートされますが、SELECTステートメントでもMATCH()を使用する事でORDER BY句で関連度順ソートを指定する事ができ、statusと併せてソートする事も可能です。ただ、前者に比べ後者の方がソートのオーバーヘッドがかかるため後者は採用しませんでした。 Elasticsearch +優先+募集終了 GET /item_search/_search { &quot; track_total_hits &quot; : true , -- 表示する案件は1ページあたり30件のためsizeを30で指定 &quot; size &quot; : 30 , &quot; query &quot; : { &quot; bool &quot; : { &quot; must &quot; : [ { &quot; match &quot; : { &quot; search_string &quot; : { &quot; query &quot; : &quot; Java PHP &quot; , &quot; operator &quot; : &quot; or &quot; } } }, { &quot; match &quot; : { &quot; search_string &quot; : { &quot; query &quot; : &quot; 東京 &quot; , &quot; operator &quot; : &quot; and &quot; } } } ], &quot; should &quot; : [ { &quot; terms &quot; : { &quot; item_id &quot; : [ 25000 ], &quot; boost &quot; : 10000 } }, { &quot; terms &quot; : { &quot; item_id &quot; : [ 30000 ], &quot; boost &quot; : 8000 } }, -- &lt;中略&gt; -- { &quot; terms &quot; : { &quot; item_id &quot; : [ 45000 ], &quot; boost &quot; : 2000 } } ] } }, &quot; sort &quot; : [ { &quot; status &quot; :{ &quot; order &quot; : &quot; asc &quot; } }, { &quot; _score &quot; :{ &quot; order &quot; : &quot; desc &quot; } } ] } 上位表示する優先データはスコアをブーストする方法で実現しています。 切替時のプログラム変更 データ作成 LaravelのScoutを用いて、Auroraの検索テーブルを追加・更新した場合Elasticsearchに自動的に同期される作りにしました。 そのため、モデルをElasticsearchと自動的に同期させるモデルオブザーバを登録するために、検索可能にしたいモデルにLaravel\Scout\Searchableトレイトを追加しました。 use Laravel\Scout\Searchable; class Post extends Model { use Searchable; } 検索 以下のクラスを作成し、Elasticsearchで検索できるように設定しました。 app/Providers/ElasticsearchServiceProvider Scoutを使ってElasticsearchを使えるようにする設定を記載 app/Scout/ElasticsearchEngine ScoutからElasticsearchを操作する内容を記載 また、SQLを呼び出す側だったRepository層では、SQLからElasticsearchのクエリに書き換えました。 形態素解析 php-mecab 形態素解析はphp-mecabを利用 スキルテーブルや駅テーブルの名詞をdictに登録 案件データ作成や検索処理以外でも形態素解析の処理がある ES切り替え後もkuromojiだけに出来ない Elasticsearch kuromoji mecab登録した内容はkuromojiにも登録 検索用データの工夫 1文字は単語として認識しないため、検索データ・検索クエリ共に変換して処理しています。 認識しない記号も同じように変換しています。 駅名は検索データに「渋谷」「渋谷駅」の2つを登録し、検索パネルの場合「渋谷駅」に変換し「渋谷区」の別の駅にヒットしないようにしています。 これらの文字列はmecab、kuromojiに登録しておきます。 設定 Aurora(MySQL) 1単語認識文字数 デフォルトは最小4文字ですが「DB」「東京」などあるため2で設定します。 ft_min_word_len=2 innodb_ft_min_token_size=2 変更する場合はDB再起動が必要なので必ず先にやっておきましょう。 ngram MySQL FULLTEXT インデックスはWITH PARSER を指定してINDEXを作ります。 ngramするとアルファベットがバラバラになる "baby" を "ba", "ab", "by"にトークナイズする ngramしないと文章内の日本語がうまく取れない スキル(PHP・Linuxなど)での検索の重要性が高いためngram無しでINDEXを作成しました。 ALTER TABLE `item_searches` ADD FULLTEXT KEY `idx` (`search_string`); 例えば「...業務内容はブリッジSEとして...」を解析しても「ブリッジSE」ではヒットしない挙動となり、 Elasticsearch移行前の大きな課題でした。 Elasticsearch トークナイザー modeをnomalに設定することで、辞書登録した文字列を1単語として単語認識させることができます。 &#34;tokenizer&#34; : { &#34;kuromoji&#34; : { &#34;mode&#34; : &#34;normal&#34;, &#34;type&#34; : &#34;kuromoji_tokenizer&#34; } } モードがデフォルトのsearchだと、例えば javascriptを形態素解析した場合、javascriptの他に java script としても分解されるため、検索結果にjavaの案件が含まれる事になります。 今回の事例では辞書登録した単語を1単語として単語認識させることで検索結果の精度を上げています。 Character Filter ※2022/03/10 追記 char_filterで検索文字列の大文字を小文字に統一する設定も行なっています。 &#34;char_filter&#34;: { &#34;lc_normalizer&#34;: { &#34;type&#34;: &#34;mapping&#34;, &#34;mappings&#34;: [ &#34;A =&gt; a&#34;, &#34;B =&gt; b&#34;, &#34;C =&gt; c&#34;, --- &lt;中略&gt; --- &#34;Z =&gt; z&#34; ] } }, Character Filterを使い入力文字列を小文字に正規化することで、大文字・小文字に関係なく(※)辞書登録されている文字列に一致させることができます。 ※ ユーザ辞書を作成する際にアルファベットは小文字限定で作成している、という前提があります。 エイリアス 必要な理由 インデックスはフィールド追加やKuromojiの内容変更などで作り直す事がある プログラムからインデックスを直接参照すると作り直すタイミングでサービスに悪影響が出てしまう 構造 エイリアス item_search インデックス item_search_yyyymmdd 変更時はインデックスを作り直しAliasに紐付け検索データを切り替える リリース手順の変更 artisanコマンドを作成し、以下を実行しています。 スキル・駅などのデータを取得しmecabのdictを作成 Elasticsearchインデックスの変更がある場合のみ以下も実行 定義に従いインデックスを作成 item_search_yyyymmdd スキル・駅などのデータをKuromojiに登録 Aurora item_search から Elasticsearch item_search_yyyymmdd へデータをコピーするプログラムを実行 エイリアスを切り替え 終わりに 簡単ではありましたがElasticsearchを採用するに至った背景や理由、MySQLからElasticsearchへ移行した結果どれくらいパフォーマンスが改善したかを紹介しました。 今回の事例のように、レバレジーズでは事業課題を解決するための技術選定やプロダクト改善をエンジニアが主導して行うことができます。 このような主体的に提案ができる環境で是非一緒に働きませんか? レバレジーズ フリーランスHub開発チームでは一緒にサービスを作ってくれる仲間を募集中です! ご興味を持たれた方は、下記リンクから是非ご応募ください。 leverages.jp 補足 見通し 案件(求人)数 事業モデルが現状だと数年運用しても100万件には達しない スペックアップが簡単など充分コントロール可能なので過度な予測を行う必要がない リリース前期間の見通し 結果的に3ヶ月+12日 システムマネージャー2名と類似サービスを見ながら30分ほど打ち合わせ 楽観でチャレンジングな目標を設定 機能・非機能を洗い出しグルーピングして一覧化 ストーリーマッピングの要領でリリース前のラインを引く 最初は楽観でラインを引く 実際の開発進捗を見てライン・スケジュールどちらかを調整 どうすれば出来るかを常に考える リリース直後が最も忙しくなる 経験則ですが、例えば以下などが良くあります 初めて発生する事案や運用が多い 発見した不具合を即日で潰す 新規の契約や案件(求人)掲載の集中 CREATE MySQL FULLTEXT インデックス CREATE TABLE `item_searches` ( `id` int( 11 ) NOT NULL , `item_id` int( 11 ) NOT NULL , `base_value` int( 11 ) DEFAULT NULL , `status` tinyint( 4 ) NOT NULL , `search_string` text NOT NULL , `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP , `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ALTER TABLE `item_searches` ADD PRIMARY KEY (`id`), ADD KEY `item_searches_item_id_index` (`item_id`) USING BTREE, ADD FULLTEXT KEY `idx` (`search_string`), MODIFY `id` int( 11 ) NOT NULL AUTO_INCREMENT; Elasticsearch PUT /item_search?pretty { &#34;aliases&#34; : { &#34;item_search_replace&#34; : { } }, &#34;settings&#34; : { &#34;index&#34; : { &#34;max_result_window&#34; : &#34;500000&#34;, &#34;analysis&#34;:{ &#34;char_filter&#34;: { -- 2022/03/10追記 &#34;lc_normalizer&#34;: { &#34;type&#34;: &#34;mapping&#34;, &#34;mappings&#34;: [ &#34;A =&gt; a&#34;, &#34;B =&gt; b&#34;, &#34;C =&gt; c&#34;, --- &lt;中略&gt; --- &#34;Z =&gt; z&#34; ] } }, &#34;filter&#34; : { &#34;greek_lowercase_filter&#34; : { &#34;type&#34; : &#34;lowercase&#34;, &#34;language&#34; : &#34;greek&#34; } }, &#34;tokenizer&#34; : { &#34;kuromoji&#34; : { &#34;mode&#34; : &#34;normal&#34;, &#34;type&#34; : &#34;kuromoji_tokenizer&#34;, &#34;user_dictionary_rules&#34; : [ &#34;Java,Java,Java,スキル&#34;, &#34;PHP,PHP,PHP,スキル&#34;, -- &lt;中略&gt; -- &#34;渋谷駅,渋谷駅,渋谷駅,駅名&#34;, &#34;東京駅,東京駅,東京駅,駅名&#34; ] } }, &#34;analyzer&#34; : { &#34;analyzer&#34; : { &#34;type&#34; : &#34;custom&#34;, &#34;char_filter&#34; : &#34;lc_normalizer&#34;, -- 2022/03/10 追記 &#34;tokenizer&#34; : &#34;kuromoji&#34;, &#34;filter&#34; : [&#34;kuromoji_baseform&#34;, &#34;greek_lowercase_filter&#34;, &#34;cjk_width&#34;] } } } } }, &#34;mappings&#34; : { &#34;properties&#34; : { &#34;id&#34; : { &#34;type&#34; : &#34;integer&#34; }, &#34;item_id&#34; : { &#34;type&#34; : &#34;integer&#34; }, &#34;search_string&#34; : { &#34;type&#34; : &#34;text&#34;, &#34;analyzer&#34;: &#34;analyzer&#34;, &#34;fields&#34; : { &#34;keyword&#34; : { &#34;type&#34; : &#34;keyword&#34;, &#34;ignore_above&#34; : 256 } } }, &#34;base_value&#34; : { &#34;type&#34; : &#34;integer&#34; }, &#34;status&#34; : { &#34;type&#34; : &#34;integer&#34; }, &#34;created_at&#34; : { &#34;type&#34; : &#34;date&#34; }, &#34;updated_at&#34; : { &#34;type&#34; : &#34;date&#34; } } } }
はじめに こんにちは。レバレジーズ株式会社エンジニアの加藤です。 今回は、私が所属しているHRテック事業部における新規SaaSの開発についてご紹介したいと思います。 事業領域について 私たちの事業部が扱う領域はHRTechといい、主に会社の人事業務に対するサービスを展開しています。「人事」と聞くと採用活動をイメージされることも多いですが、ここでは「従業員が入社し、退職するまでに関わる業務の全般」を指します。そのため、採用管理や勤怠管理、タレントマネジメント、給与管理などをはじめとした、さまざまなSaaSが存在しています。勤怠管理サービスなどは使ったことのある方も多いのでイメージしやすいのではないでしょうか。 HRTechの役割 人事業務は人材採用、人材育成、離職対策、人事評価、労務管理など多岐に渡り、従業員が入社から退職するまでなくてはならない業務です。適切に人事業務が提供されない会社では従業員は力を発揮できませんし、当然ながら定着もしません。つまり、人事業務が最適化されているかどうかは「会社の発展や成長」を大きく左右することになります。また、会社規模が大きくなればなるほど、人事業務の複雑性は指数関数的に増していきますので、ますます仕組みとして整っていなければなりません。そのため、手続きや情報の管理を効率的に行い、組織設計、採用、人材育成、離職対策など有効な施策立案に繋げ、人事の価値創出に寄与することがHRTechサービスに求められる役割です。 HR(人事)領域の抱える課題 しかしながら、既存のサービスは機能によって(採用管理・勤怠管理・労務管理といったように)分類化されており、人事業務を包括的にカバーできるものはありません。 例えば、「採用・人材育成・離職対策」といった機能は相互に関係性を持ちますが、それぞれの機能ごとにプラットフォームが分断されていれば、統合された情報を元にした適切な人事オペレーションを提供することは難しくなります。 既存の人事業務を各機能ごとにデジタル化(またはDX化)することはできても、HRTechを利用することで人事の価値創出に繋げることができていないということが、人事業務とHRTechという領域の課題といえます。 私たちのプロダクトの意義 前述の課題に対して、私たちのサービスでは例えば、各人事業務で蓄積された従業員の志向性やエンゲージメント、実績や評価などの情報を元に個別適切な配置転換や離職対策に活用することができます。 昨今の潮流としても、企業は労働者の価値観やワークスタイルニーズの多様化などにより、以前よりもパーソナライズされた労働環境の提供が求められています。 より個に適した形で人事業務を行うには、分断されたプラットフォームではなく、包括的に情報を管理できる環境が必要になります。 チーム体制について 私たちの事業部ではマイクロサービスアーキテクチャを採用しており、開発体制としてはスクラムを採用しています。またスクラムの中でもLeSSと呼ばれるものに近く、4-6人程度を1群としたチームが4チームほどで動いています。それぞれのチームが1つのプロダクトを開発するよう自己組織化されています。 チーム体制のメリット 意思決定の速さ 事業部としての大きな方針はありますが、チームのための意思決定はチーム独自で行っています。人員構成もエンジニアが主体となっているためエンジニア主導でコトが進みその速度も速いです。少人数ゆえメンバー1人1人の意見がダイレクトにチームに反映されます。 業務の範囲に制限がない 担当業務は単純な作業に留まることはなく、フロントエンドやバックエンド、インフラを問わず機能の要否や設計から実装まで行います。 作業範囲が広いため、様々な技術に触れながら成長することができますし、事業計画に直接携わるなど、エンジニアに留まらない仕事の仕方ができる可能性もあります。 もちろん作業範囲が広いだけではありません。例えばプロダクトの仕様についても、単に仕様を満たすための部分的な作業ではなく、プロダクト全体、ひいてはサービスの成長を見据えた上で設計を行うため、事業経営の中核に立って開発を進めることができます。 コミュニケーションの取りやすさ 少人数のチーム構成のため、メンバーと自分の作業関係が把握しやすく、開発に関するやりとりも齟齬なく行うことができます。 開発業務はハードですが、他のチームも含めて非常に安心感のある組織だと自信を持って言えます。 使用技術について TypeScript フロントエンドとバックエンドの使用言語を揃えることで、同じ文法で開発でき、実装者の学習コストを下げ、ソースコードの流用もしやすくなります。 実装者もフロントエンド、バックエンドを問わず実装に参加しやすくなり、フロントエンドもバックエンドも実装したい開発者にとっては魅力だと思います。(ごく一部PHP, pythonもありますがほとんどはTypeScriptです。) GraphQL オーバーフェッチングやエンドポイントの複雑化などREST APIの課題を解決するために生まれました。 AirbnbやNetflix、Shopifyなどをはじめとした多くの企業で使用されています。 クライアントとしてApollo GraphQLを利用しており型安全なAPI開発、利用が実現できています。 Google Cloud Platform 私たちのチームでは、外部サービスの利用を除きプロダクトのバックエンドインフラをGoogle Cloud Platform(以下GCP)の利用に振り切って開発しています。 Cloud RunやCloud SQLをはじめとしたマネージドサービスを利用することで、開発者はコンテナやコンテナ内部のアプリケーションの開発に専念することができます。 Cloud PubsubやCloud Build、Cloud Monitoringなども利用しており、GCPインフラでのDevOpsやSREに興味のある方にとっても魅力の高い環境かと思います。 下記に簡単に仕様技術の一覧をまとめています。 FE React Apollo Client Material-UI BE GraphQL Express/NestJS TypeORM Docker infra GCP Cloud Run Cloud SQL Cloud Storage Cloud Pub/Sub Cloud Build Cloud Memorystore(redis) Cloud Monitoring App Engine Firebase CI/CD Github Actions Cloud Build external-service SendGrid(email delivery) Auth0(IdaaS) Stripe(payment) おわりに いかがでしたでしょうか。 私の所属するHRテック事業部は発足してまだ日が浅く、プロダクトの状況も0-1フェーズにあります。しかし、だからこそ今回ご紹介したような魅力を強く感じられる組織であると思っています。私自身もレバレジーズに入社してまだ1年未満ですが、事業内容や使用技術、働き方など、非常に挑戦的で魅力のある環境だと実感しています。この記事をご覧になり、この事業部を一緒に盛り上げてみたいなと思う方が1人でも増えれば嬉しく思います!
本文 はじめに  レバレジーズ株式会社 テクノロジー戦略室室長の竹下です。レバレジーズは創業以来事業の成長が続いていますが、ここ最近は事業の拡大にシステムの開発が追いつけていない状態でした。そのため、システム開発が事業の拡大に追いつき、更には加速させるためにマイクロサービス化を主体においた技術スタックの刷新を進めています。この記事では、導入したまたは導入しようとしている主な技術の紹介と、選定した技術が目先の開発だけではなくエンジニア組織全体の課題をどのように解決するかを紹介していきたいと思います。 導入技術紹介  まず、現在導入したまたは導入しようとしている主な技術のご紹介から始めます。 マイクロサービス化 レイヤードアーキテクチャ(クリーンアーキテクチャ、ヘキサゴナルアーキテクチャ) DDD TypeScript gRPC gRPC-web GraphQL Github actions BDD/ATDD(cucumber) コード生成 CDK Docker ECS Fargate Service Mesh DataDog 列挙した技術がざっくりどの領域に関係するかの図はこちらになります。  こちらに紹介しているのは主要な技術のみで、他にも様々な技術検証、導入を進めています。それらはこれからの弊社の技術者ブログを楽しみにしていただければと思います。また、個別の技術の紹介もこちらの記事では詳しく行いませんので、あしからず。 事業成長にシステムが追いつくための課題  それでは、本題の技術導入で解決したい課題を説明していきます。  まず弊社の開発体制ですが、基本的には1サービス毎にチームが付き、サービス毎にコードベースを分けて開発を行ってきました。  レバレジーズ主力事業である『レバテック』は、サービスを使ってくださるエンジニアの方や、企業様が年々増えており順調に成長を続けてきました。また、『レバテックダイレクト』や『レバテックカレッジ』をはじめとするレバテックシリーズの新サービスや、看護師転職支援サービス『看護のお仕事』、ITエンジニア向けQAサイト『teratail』などのサービスも提供をしてきました。しかし、開発サイドでは、長年機能とビジネス範囲の拡張を続けたことによるコードベースの肥大化や、サービス間のデータ連携のために本来独立しているはずのチームが密に結合してしまい、人を増やすだけでは事業成長に追いつけなくなってしまいました。特に 相互依存する、複数の巨大モノリシックサービス 標準化の欠乏 自動化の欠乏 の3つによって、組織としての開発効率が下がってしまっていました。 相互依存する、複数の巨大モノリシックサービス  モノリシックサービスとは、一つのプログラムで一つのサービスを作ることを指します。 弊社もこれまではモノリシックにサービスを開発してきましたが、長年機能拡張を続けてきた結果、PHPで開発してきたことも相まって、コードを一部修正した場合に影響範囲がどこまで及ぶがわかない状態となり、影響の確認のために多大な工数がかかるようになってしまいました。また、サービス間でのデータ連携も行われているため、影響範囲が1つのサービスだけに留まらず他のサービスを巻き込んでバグを出すということも頻発するようになってしまっていました。  さらに、一つのプログラムが受け持つビジネス領域も拡大してきた結果、必要な業務知識も膨らみ、新しいエンジニアがまともに開発できるようになるまでにも多くの時間がかかるようになっていました。 標準化の欠乏  弊社では基本的に1サービス1チームが割り当てられ、コードベースも分けて開発を進めています。しかし、これまではコード規約ぐらいしか標準化されたものが無く、開発スタイルや設計、インフラ構築方法もチームでバラバラでした。そのため、スポットで他チームへヘルプに入ったり、ノウハウの共有が困難でした。  新規サービス開発の際も、機能の流用ができる作りになっていないので、各サービスで同じような機能をバラバラに作るということも多く発生していました。 そのため、チームが増えても相乗効果が発生せず、組織としての開発効率がいまいち上がらない状態になっていました。 自動化の欠乏  CI/CDが入っていないサービスも多々あり、デプロイやインフラ構築もシェルを手動実行するものが数多く残っていました。そのため、開発面ではチーム毎、開発者ごとにコードの品質が揃わなかったり、運用ではデプロイが限られた人しか実行できなかったり、新しいサービスを作る際にもインフラ構築に工数が多くかかる上に、インフラの監視やログ監視も抜け漏れが発生するなど、開発効率の低下や品質の低下を招いていました。 導入技術がどのように課題を解決するか  上記の課題を解決するのは一つの技術だけでは解決できないため、マイクロサービス化を中心においた技術スタックの刷新を行い、技術の組み合わせによって解決を目指しました。 マイクロサービス化とレイヤードアーキテクチャを主体とした分割統治  プログラムの設計において分割統治という考え方があります。大きな問題を小さな問題に分割して、小さな問題をすべて解決することで全体を解決するという考え方です。  サービスのフロント、BFF、バックエンドの3層への分割、ビジネスドメインを再整理しドメインに分割しマイクロサービス化、一つのマイクロサービス内でのレイヤードアーキテクチャによる分割を行っています。それにより、 プログラム変更の影響範囲が限定される 開発者が知る必要があるドメイン知識が減る ユニットテストが容易になる などのメリットを享受でき、結果、長期に渡り開発効率を高い状態で維持することが期待できます。 TypeScript、gRPC, GraphQLなど型システム技術導入によるチーム間コミュニケーションの円滑化  マイクロサービス化、レイヤードアーキテクチャ化することでチームは細分化されることになるので、チーム間のコミュニケーションが重要になってきます。そのため、フロントエンド、バックエンドともにTypeScriptへ切り替え、マイクロサービス間はgRPC、フロントエンドとバックエンド間はgRPC-webまたはGraphQLを導入しました。gRPC、gRPC-web, GraphQLのIDL(interface definition language)が、そのままAPI仕様書となり、IDLからコードを自動生成しコンパイルを行うことで変更点をコンパイルエラーとして検出できるため、仕様変更した際の伝達が容易かつ安全に行えるようになります。 CI/CD、コード生成、BDD/ATDDの活用による品質の担保  レイヤードアーキテクチャ化によるユニットテストの容易化、TypeScript化によるコンパイルエラーによるエラー検知を活用するためCI/CDとしてgithub actionsを導入しソースコードのPush時に自動で検証できるようにしています。  TS化、CI/CDでエラー検知が容易なったことでコード生成も最大限活用できるようになりました。gRPCのIDLからのコード生成の拡張によりレイヤードアーキテクチャのためのボイラープレートの生成や、DBアクセスオブジェクトからの安全なSQL発行コードの生成などを導入することで、開発速度の向上に加えコード品質の安定化も行っています。  一部のチームではユニットテストに加えてcucumberを導入して、マイクロサービスのAPIをBDD(振る舞い駆動開発)、フロントエンドをATDD(受け入れテスト駆動開発)しています。実装前にエンジニア同士やディレクター-エンジニア間で仕様のすり合わせと検討が可能になり、手戻りが減った上に設計品質の向上にも繋がりました。 IaCによるインフラの既製化とDevOps推進  弊社はAWSをメインに使用しているため、インフラの構築はCDKを導入しました。CDKはTypeScriptやJavaなどプログラミング言語を使ってインフラを構築できる技術です。アプリサーバーの構成がすべて、フロント、BFF、バックエンドの3層化しDocker+ECS Fargateでサーバーレス化したことで、同じインフラ構築のコードをどのチームでも使い回せるようになりました。CDKをラップした社内ライブラリを用意したことで、アプリエンジニアがインフラ構築から行えるようになり、かつ、サーバーの構築が共通化されたことで監視の漏れや設定のミスもなくなりました。  現在は更にDataDogの導入も行い、インフラ監視、ログ監視などを強化しています。  また、マイクロサービス化が進み、サーバーも増えてきているため、ServiceMeshも導入を進めており、より緻密で効率的なサーバー管理を目指しています。 便利さによる標準化の浸透  技術選定したはいいものの浸透しないということもあると思います。大体は新しい技術を覚えたり切り替えることに対する不安が原因なことが多いです。今回選定した技術に関してほぼ全て社内ライブラリを用意し弊社の開発に特化させることで、 新しいことを覚えるコスト &lt;&lt;&lt; 選定された技術を使うメリット という状況を作り出しました。一つの技術選定だけでは実現は難しいですが、複数の技術をうまく組み合わせその状況を作り出せれば、あとは自己増殖的に浸透してくれます。 おわりに  レバレジーズは「関係者全員の幸福を追求する」ミッションを達成するため、今回ご紹介したように、開発効率を高め、事業実現を素早く行えるエンジニア組織への変革をすすめています。そのため技術選定も技術的好奇心や目下の開発だけにとらわれない選定を心がけて行っています。しかし、まだまだ改善できるところは多々あります。もし「技術選定を行って組織を変えていきたい」と思っている方がいましたら、ぜひ一緒に働きませんか?エキサイティングな仕事ができると思います。 ご興味ある方は、こちらからエントリーをお願いします! https://recruit.jobcan.jp/leverages/ recruit.jobcan.jp
はじめに こんにちは! エンジニアの室井です。 近年、レバレジーズは事業の急拡大を続けていますが、その中核となっている「 Dataforce(データフォース) 」と呼ばれる自社プロダクトをご紹介します。 Dataforce(データフォース)とは? Dataforce(データフォース)はレバレジーズが内製化しているSFA ※1 です。 ※1 営業業務の効率化を図り、売上や利益の向上を実現する営業支援システム、Sales Force Automationの略称 Dataforceは現在、看護師の紹介事業・派遣事業、そして介護士の紹介事業・派遣事業の4つの事業で利用されています。 国民の4人に1人が65歳以上という少子高齢社会を迎えた今、医療従事者の需要は日本全体で高まっていますが、依然として十分に供給が追いついておらず、医療・介護業界における人手不足が発生しています。 だからこそ我々SFA開発チームは、Dataforceを通して営業業務の効率化を向上させることで、病院や施設の抱える人手不足問題の解消に貢献することができると考え、日々取り組んでいます。 Dataforceが持つ主な機能は次の3つです。 1. 求職者、事業所管理機能  求職者、事業所に関する基本情報や接触履歴など全ての情報を統合管理する機能です。 顧客から問い合わせが入ったときに担当者が不在でも、顧客管理画面を呼び出せば相手先の情報がひと目でわかり、担当者以外の人の的確かつ素早い対応が可能です。 2. フロー管理機能  求職者に求人を紹介をする段階から、面接日程調整・入職までの流れの中で、個々のフローを管理する機能です。 ひとつひとつのフローについて、顧客情報、求人情報、営業フェーズ、入職予定日など、紐づけられた関連情報を俯瞰することができます。 3. 活動管理機能  営業活動における行動や結果を数値化して管理する機能です。 担当者の案件数、営業実績などの情報を管理・蓄積することができるため、成績の良いメンバーの行動パターンを、他のメンバーに共有すれば、チーム全体のパフォーマンスの底上げにもつながります。 Dataforceの強み Dataforceには多くの強みがありますが、今回はその中から2つご紹介します。 1. 営業業務に必要な機能の集約  LINE to Call や Twillo などの外部連携APIを使用してDataforce内でLINE、SMS、電話といった顧客とのコミュニケーションで使用するツールが利用できます。 これまでバラバラに管理・運用していたツールを一元化することによって、データ統制のみならず、営業業務の効率化や生産性向上を実現しています。 2. 営業向けレコメンド機能  営業業務のサポート機能として、独自のアルゴリズムを基にした求人レコメンド機能があります。この機能は、求職者の希望条件に近い求人をキャリアアドバイザー(営業)へとレコメンドしてくれる機能であり、新人教育や早期立ち上がりなど、さまざまな面で営業組織をサポートしています。 Dataforceの開発チーム 開発チームは、施策の立案から、要件定義 / 仕様設計 / スケジューリングまでを担当するディレクターと、フロントエンドやバックエンドにおけるアーキテクチャ設計 / 実装設計 / 実装 / コードレビューを行うエンジニアで構成されています。 開発手法は、2週間単位でスプリントを設定したオリジナルのスクラム開発で行なっており、スプリントの始まりに工数見積もり会を開き、終わりにKPTを用いてスプリントの振り返り確認をします。 業務内容はエンジニアリングに限らず、基本的に制限がないことから、意欲があれば誰もが色々な業務に挑戦できる組織です。 チームの特徴としてよくあがることが2つあります。 1. 開発サイクルが速い  スクラム開発体制を採用することで、ディレクターとエンジニアを同じチーム内に配し、コミュニケーションコストを削減することができ、システムを企画・開発するサイクルが速いです。 2. 主体的な人が多い  チームやシステムをより良くしたいと主体的に動く人が多いことも特徴の一つです。 日頃から改善点がないか探している人が多いので、エンジニアの業務改善なども頻繁に行われています。 エンジニアの業務改善にとどまらず積極的に施策を立案したり、UI/UX改善を行ったりなど、様々なことにチャレンジしています。 技術スタック ■Laravel  Dataforceを利用する事業が多岐にわたることから、営業業務プロセスを理解しなければ開発ができないのはよくあることです。しかし、それではプロジェクトに参画した人が即戦力として活躍することが難しくなってしまいます。 そのため、Laravelのサービスコンテナの仕組みやパッケージ開発の容易性を活用し、ルールや事業知識がなくても良い基盤を設計し、パッケージをリポジトリに切り分けることで効率的な開発ができるだけでなく、誰でもすぐに即戦力となり活躍することができます。 ■AWS  Amazon TranscribeやAmazon Comprehendを使用して、顧客とのヒアリングなどで得られる情報をシステムに自動入力することで、営業業務を削減したり、リアルタイムで行われる対話から顧客の感情を解析しCX最適化を行うなど、音声分析に取り組んでいます。 【その他利用技術(一部)】 開発言語 PHP JavaScript TypeScript Python フレームワーク Laravel Vue.js インフラストラクチャ AWS : EC2,ECS,S3,RDS,ElastiCache,Lambda ミドルウェア nginx DB、検索エンジン PostgreSQL Elasticsearch 構成管理ツール CloudFormation CI/CD CircleCI GitHub Actions 監視ツール Cloudwatch その他ツール、サービス Docker GitHub sendgrid(email delivery) Twillio LINE to Call おわりに Dataforceチームのエンジニアとして働くことには2つの魅力があります。 1. エンジニアとして大きく成長できること  Dataforceチームでは、様々なAPIを用いた開発やレコメンド機能を用いた開発など、色々なプロジェクトに携わることができます。そのため、幅広い経験をすることができ、大きな成長ができます。 2. プロダクト設計など開発以外の仕事にも挑戦できる  Dataforceチームのエンジニアは本来の開発業務以外の動き方も希望に合わせてできます。 社内に利用ユーザーがいるため、ユーザーにヒアリングを行いながら新規施策の立案やUI/UX改善などに挑戦することが可能です。 立案した施策で業務効率や売上が向上した時は、大きなやりがいを得ることができます。 弊社の情報公開ポリシーのため、機能について深くお話はできませんでしたが、ブログに公開できないことでも、会社説明会やカジュアル面談などではお話ができることもありますので、Dataforceについてご興味がある方はぜひ質問してみてください! レバレジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です! ご興味のある方は、以下のリンクから是非ご応募ください。 https://recruit.jobcan.jp/leverages/ recruit.jobcan.jp
はじめに こんにちは、20卒でレバレジーズ株式会社に入社した古賀です。 現在私は、若年層領域の事業を複数展開するヒューマンキャピタル事業部に所属しており、営業支援システムの開発に携わっています。現在は、リプレイスに取り組んでいます。私を中心にバックエンド、フロントエンドの技術選定を行い、GraphQL、React、Apollo Clientを採用しました。 この記事では、Apollo Clientを採用した理由、デメリットを補うために行った設計上の工夫をお伝えします。なお、GraphQL、Reactを採用した理由については詳細に説明はしない予定です。 前提 バックエンドにはGraphQLを採用することにしました。採用した目的は下記となります。 営業支援システムや周辺サービスに存在する、似たようなロジックをGraphQLで抽象化し実装速度を向上するため クライアントファーストなAPI設計にすることで、フロントエンドリソース不足を解消するため Apollo Clientとは、GraphQL専用APIクライアントを兼ねた状態管理ライブラリです。下記のような特徴があります。 取得データ、ロード、エラーの状態のロジックがApollo ClientのフックAPIにカプセル化されており、これらに関して宣言的に記述可能 フェッチしたデータが自動的に正規化されキャッシュされる Apollo Clientでは、キャッシュをReduxでいうstoreとして利用し状態管理することが可能です。詳しくは 公式 をご覧ください。 状態管理で抱えていた問題 フロントエンドはjQueryとReact/Reduxが混在。jQueryからReact/Reduxに書き換えていたため、「サーバーからデータ取得→store格納までの処理の記述量が多い」問題が発生していました。 状態管理ライブラリの選択 上記問題を解決するために、状態管理ライブラリをApollo Clientに選択しました。 選択の流れについて説明します。候補は、既存のReduxに加え、バックエンドがGraphQLなのでApollo Client、Relayとなり、Relayは情報が少ないこともあり除外。次に、Apollo Clientのメリット・デメリットを調査しました。 メリット キャッシュ機構をstoreとして利用することで、サーバーから取得したデータをstoreに格納する処理が不要になり、コード量が削減される storeの設計が不要になり設計工数が削減される デメリット どこからでもAPIリクエストしキャッシュが更新されるので、バグ発生箇所の当たりをつけづらく開発効率が低下する 「サーバーからデータ取得→store格納までの処理の記述量が多い」問題は解決できそうですが、新たなデメリットが発生しそうな懸念があったため、Fragment Colocationで解決できると考え、導入しました。 デメリットのカバー 子コンポーネントで欲しいデータをFragmentで定義、親コンポーネントで欲しいデータを子コンポーネントのFragmentを組み合わせて定義、最上位階層の子コンポーネントで親コンポーネントのFragmentを組み合わせてQueryを定義し一括取得する手法になります。詳細は Apollo Clientの公式 を御覧ください。 トップのコンポーネントで取得したデータは、コンポーネントの再利用性を高めるためにprops経由で子コンポーネントに流し込むことにしました。 ディレクトリ構成は下記になります。 lib ├── atoms ├── molecules ├── organisms │ └─ parts/ │ │ └─ {コンポーネント名}.tsx │ │ └─ {コンポーネント名}.stories.mdx │ └─ {モデル名}/ │ └─ {コンポーネント名}/ │ └─ Container.tsx:この中にQuery, Mutation, Fragmentも書く。 │ └─ Presentation.tsx │ └─ Story.stories.mdx └─── pages/ └─ {画面名}/ └─ Container.tsx └─ Presentation.tsx 下記サンプルコードになります。Fragment Colocationについて示したいため、Query部分のみ記載してます。フックや型はgraphql-codegenで生成したものを使用しています。 organisms/Job/JobClient/Container.tsx のFragmentをまとめ上げて取得していることが分かります。 Fragment Colocationとは別ですが、サーバーから取得しないが、コンポーネント間で共有したいデータはuseReducerを利用。 pages/Sample/Container.tsx import React , { useEffect , useReducer } from 'react' import { Presentation } from './Presentation' import { gql } from '@apollo/client' import { ClientTest , JobTest } from '@/ts/objects/organisms/Job/JobClient/Container' import { useIndexQuery } from 'generated/graphql' const INDEX_QUERY = gql ` query index { clients { ...ClientTest } jobs { ...JobTest } } ${ ClientTest } ${ JobTest } ` export interface Store { isInit: boolean selectedClient?: string selectedJob?: string } type ActionType = 'SET_INIT' | 'SET_VALUE' | 'SET_CLIENT' export interface Action { type : ActionType payload?: any } const reducer: React.Reducer &lt; Store , Action &gt; = ( store , action ) =&gt; { switch ( action. type) { case 'SET_INIT' : return { isInit: false , selectedClient: String ( action.payload.clients [ 0 ] .id ), selectedJob: String ( action.payload.jobs [ 0 ] .id ), } case 'SET_VALUE' : return { ...store , [ action.payload.name ] : action.payload.values , } case 'SET_CLIENT' : return { ...store , selectedClient: action.payload.values , selectedJob: action.payload.jobs !== 0 ? String ( action.payload.jobs [ 0 ] .id ) : '1' , } default : return store } } export const Container: React.FC = () =&gt; { const { data , loading , error } = useIndexQuery () const initialStore: Store = { isInit: true , } const [ store , dispatch ] = useReducer ( reducer , initialStore ) useEffect (() =&gt; { if ( ! data || ! store.isInit ) { return } dispatch ( { type : 'SET_INIT' , payload: data , } ) } , [ data ] ) if ( store.isInit &amp;&amp; loading ) { // 必要に応じてカスタマイズ return &lt; div &gt; Loading... &lt; /div &gt; } if ( error ) { // 必要に応じてカスタマイズ return &lt; div &gt; Error ... &lt; /div &gt; } return &lt; Presentation data = { data } store = { store } dispatch = { dispatch } / &gt; } pages/Sample/Presentation.tsx import React from 'react' import { JobContainer } from '@/ts/objects/organisms/Job/JobClient/Container' import { Store , Action } from './container' interface Props { data: any store: Store dispatch: React.Dispatch &lt; Action &gt; } export const Presentation: React.FC &lt; Props &gt; = ( { data , store , dispatch } ) =&gt; { return ( &lt;&gt; &lt; JobContainer clients = { data.clients } jobs = { data.jobs } selectedClient = { store.selectedClient ?? '1' } selectedJob = { store.selectedJob ?? '1' } dispatch = { dispatch } / &gt; &lt; / &gt; ) } organisms/Job/JobClient/Container.tsx import React from 'react' import { ClientTestFragment , JobTestFragment } from 'generated/graphql' import { gql } from '@apollo/client' import { JobPresentation } from '@/ts/objects/organisms/Job/JobClient/Presentation' export const ClientTest = gql ` fragment ClientTest on Client { id name } ` export const JobTest = gql ` fragment JobTest on Job { id name clientId } ` interface Props { clients: Array &lt; ClientTestFragment &gt; jobs: Array &lt; JobTestFragment &gt; selectedClient: string selectedJob: string dispatch: React.Dispatch &lt; any &gt; } export const JobContainer: React.FC &lt; Props &gt; = ( { clients , jobs , selectedClient , selectedJob , dispatch } ) =&gt; { const handleChangeClient: React.ChangeEventHandler &lt; HTMLSelectElement &gt; = e =&gt; { const nextClientId = e.target.value ? String ( e.target.value ) : String ( clients [ 0 ] .id ) dispatch ( { type : 'SET_CLIENT' , payload: { name: 'selectedClient' , values: nextClientId , jobs: jobs.filter (( job: JobTestFragment ) =&gt; String ( job.clientId ) === nextClientId ), } , } ) } const handleChangeJob: React.ChangeEventHandler &lt; HTMLSelectElement &gt; = e =&gt; { dispatch ( { type : 'SET_VALUE' , payload: { name: 'selectedJob' , values: e.target.value ? String ( e.target.value ) : String ( jobs [ 0 ] .id ), } , } ) } return ( &lt; JobPresentation clients = { clients } jobs = { jobs } selectedClient = { selectedClient } selectedJob = { selectedJob } handleChangeClient = { handleChangeClient } handleChangeJob = { handleChangeJob } / &gt; ) } organisms/Job/JobClient/Presentation.tsx import React from 'react' import { ClientTestFragment , JobTestFragment } from 'generated/graphql' interface Props { clients: Array &lt; ClientTestFragment &gt; jobs: Array &lt; JobTestFragment &gt; selectedClient: string selectedJob: string handleChangeClient: React.ChangeEventHandler &lt; HTMLSelectElement &gt; handleChangeJob: React.ChangeEventHandler &lt; HTMLSelectElement &gt; } export const JobPresentation: React.FC &lt; Props &gt; = ( { clients , jobs , selectedClient , selectedJob , handleChangeClient , handleChangeJob } ) =&gt; { return ( &lt;&gt; &lt; div &gt; &lt; label &gt; 企業: &lt; select value = { selectedClient } onChange = { handleChangeClient } &gt; { clients.map (( client: ClientTestFragment ) =&gt; { return ( &lt; option value = { client.id } key = { client.id } &gt; { client.name } &lt; /option &gt; ) } ) } &lt; /select &gt; &lt; /label &gt; &lt; label &gt; 求人: &lt; select value = { selectedJob } onChange = { handleChangeJob } &gt; { jobs .filter (( job: JobTestFragment ) =&gt; String ( job.clientId ) === selectedClient ) .map (( job: JobTestFragment ) =&gt; { return ( &lt; option value = { job.id } key = { job.id } &gt; { job.name } &lt; /option &gt; ) } ) } &lt; /select &gt; &lt; /label &gt; &lt; /div &gt; &lt; / &gt; ) } 導入して分かったこと 実装したメンバーの声も含め、よかったこと、困ったことを紹介します。 よかったこと storeに格納する処理がなくなったので、想定通りコード量が減った store設計が不要になったので、設計工数を削減できた 「前提」の章で述べたとおり、非同期処理に関わるデータ、ロード、エラーの状態を宣言的に扱えて書きやすかった graphql-codegen による型生成のおかげで、型安全かつ簡単にFragment Colocationを実現できた(型安全性なフロントエンド開発については こちら ) サーバーから取得しないが、コンポーネント間で共有したいデータはuseReducerで管理した。管理したいデータ数がそんなに多くないこと、objectやarray等の複雑になりうるデータ構造を管理することも少なかったことから、useReducerでも問題なかった 困ったこと react-select の仕様で、コンポーネントに渡すために整形する処理を完全に消すことができなかった クエリやFragmentは元々他ディレクトリに切り出していたが、それぞれpages/{モデル名}配下、organisms/{モデル名}配下のContainer.tsxに定義。 理由は下記の通りです。 クエリやFragment毎にディレクトリを切ると行ったり来たりが多く開発が大変になるため、特に慣れてない初期は大変だと判断した PresentationにUI部分は切り出しており、ContainerにクエリやFragmentを記載してもファイルサイズが長大になりすぎないため Apollo Clientとは関係ないが、データを受け取るコンポーネントの再利用を考えると、Atomic Designには適切な置き場所がないので、propsをバケツリレーで渡していくことになる 最後に Reduxのstore構造の複雑さの軽減、コード量の削減、(Apollo Clientではないですが)GraphQL周辺ツールの充実さによって、開発効率を向上させることができました。 私は新卒2年目ですが、この技術選定を担当させていただきました。レバレジーズでは、経験が浅くても提案が受け入れてもらえる環境があります。このような環境に興味がある方はぜひ一緒に働きましょう。
はじめに みなさん、こんにちは! メディカルケア事業部の『看護のお仕事』にてシステム担当をしている渡辺と申します。私は2020年に新卒で入社し、同年7月からメディカルケア事業部のエンジニアとして配属されました。職種や経験年数に関わらず自発的な提案を受け入れて任せるレバレジーズの企業文化の下、私も配属後から開発プロセスの改善案を出すうちに、去年の終わり頃から媒体の責任者となり、『看護のお仕事』のサイト品質改善のためにさまざまな施策の実装を行っています。 今回は、『看護のお仕事』で開発プロセス改善を行ったことについてお話したいと思います。媒体責任者として、開発に関わるメンバーがスムーズに作業にあたることができるよう開発プロセスの改善を進めてきました。 この記事が開発プロセスの改善策を模索している方やレバレジーズに興味のある方の一助となれば幸いです。 『看護のお仕事』について 『看護のお仕事』は看護師向けの転職支援サービスです。『看護のお仕事』は無料で使うことができ、自分にあった職場を納得の行くまで探すことをサポートします。本記事をご覧いただいている方で、看護師のお知り合いがいましたら、ぜひ『看護のお仕事』をご紹介ください! 各種ミーティングについて 『看護のお仕事』の開発チームは、スクラム開発を参考にオリジナルのスクラム開発体制でサイト運営を行っています。また、スプリントは2週間単位で回しています。今回お話しする中で、関係のあるミーティングは下記になります。 施策レビュー会 参加者:オウンド責任者、ディレクター、デザイナー兼コーダー、エンジニア 内容:施策について意見を出し、話し合う会です。『看護のお仕事』のサイト品質改善を進めるにあたって施策が有効かなどを話し合うための場になります。 工数見積もりのミーティング 参加者:エンジニア 内容:実装に必要な工数を見積もります。この時に実装方針についても話し合います。見積もりにはプランニングポーカーを採用しています。 振り返り会 参加者:オウンド責任者、ディレクター、デザイナー兼コーダー、エンジニア 内容:KPTを用いてスプリントの振り返りを行います。 実際に何したの? では、実際に行った改善策について3つお話しします。 ①やるべきタスクの精査 『看護のお仕事』開発チームでは、実装が必要なタスクに工数付けを行います。エンジニア個々人のリソースと工数管理の観点から、見積もりは絶対見積もりで行っています。 私がチームへ配属されてから少し経過した頃、ABテストを行う施策に実際の作業時間が5分であるにも関わらず、1時間の見積もりを割り当てていることに気づきました。ABテストの施策は実装方法の改善がされた結果、5分で作業が出来るほどマニュアル化されており、エンジニア以外でも作業が可能なものだとわかりました。そのため、そもそもエンジニアが対応する必要があるかを見直しました。 そこで、作業を同施策の他の対応と合わせてコーダーに巻き取ってもらうことを相談し、そのようになりました。結果として、エンジニアで対応が必要かどうかの見直しができ、1時間の工数がつく施策がスプリント内で3~5施策ほどあることが続いていましたので、最大5時間の工数見積もりの削減になりました。また、施策ごとにエンジニアで対応すべきかを確認することの重要性を再確認でき、やるべきタスクの精査をよりしっかりと行うようになりました。 ②工数付けプロセス改善 工数をつけるために仕様を把握する必要があります。しかし、『看護のお仕事』開発チームでは工数をつけるときもまだ仕様が詰まりきっていないタスクが多々発生しており、そのことを関係者間で問題であるという共通認識を持てていませんでした。同時に、特定のメンバーが工数付けのミーティングまでにディレクターと短い時間で仕様についてヒアリングを行っていたため、該当メンバーに負担がありました。 この状況を改善するにあたって、ディレクターへ以下のお願いをしました。 ディレクターもしくは依頼者が、工数付けのミーティングまでに施策の実装をする上で懸念事項がないか事前に確認すること 施策レビュー会中に話がまとまらない場合には別途ミーティングの場を設けること また、エンジニアからも考慮して欲しい観点について施策レビュー会中に発言をするようにしています。 振り返り会にて相談をしたことで、仕様に抜け漏れが発生していることを共通認識として持つことができました。その結果、既存のミーティングをうまく活用することができ、仕様確認に関するやりとりなどの手間やメンバーへの負担を軽減することができました。 ③工数ズレをなくすために ソースコードレビューでは、レビューを受けてから大幅に実装方針を変更し手戻りすることや、レビュアーと実装者で追加の実装すり合わせが必要になることが多々ありました。これでは予定工数を超過してしまいますし、施策のリリースにも影響します。 その状況の根本的な原因は、レビュアーと実装者との間で実装方針のすり合わせが不十分なことでした。そこで工数付けの時間に実装方針を話し合い、その内容を実装タスクを管理しているものにコメントとして残すようにしました。このようにして、話し合った内容をいつでも確認できるようにすることでエンジニアチーム全体で共通認識をとりました。 後々の工数を減らすと同時に、実際の実装対応に近い工数を見積もることができ、工数付けの精度向上にもなりました。 終わりに これらの開発プロセス改善は私が入社後、配属半年で行ったことになります。自身で考えた改善案とその対応策を上長に提案し、チームメンバーと共に相談しながら進めてきました。このようにできたのも、社会人歴や入社年数に囚われずに話を聞いてくれてそれを受け入れてもらえる環境のおかげです。 組織は変化していくものであると同時に、開発チームという組織も流動的なものです。エンジニアチームも続々とメンバーが加わり、『看護のお仕事』がサービスとして何をすべきかのフェーズも日々進化していきます。柔軟にやり方を模索しながらエンジニア以外の方々とも協力しながら日々サイト運営を行っております。 本記事が開発プロセスの改善を進めたい方や、弊社に興味のある方に意義あるものであると大変嬉しく思います。最後までご覧いただき、ありがとうございました!
はじめに こんにちは、レバレジーズ株式会社の牛嶋です。 レバレジーズ株式会社で、teratailをはじめとしたサービスの開発に携わっております。現在、teratail管理画面のリニューアルに取り組んでおり、技術選定にあたって、CSSフレームワークとして、tailwindcssを採用しました。 この記事では、tailwindcssを採用した理由や、実際にフロントエンド開発で使ってみて良かった点や困った点を紹介します。弊チームの他のプロジェクトでは、CSS Modulesを採用しているため、それと比較しながら紹介していけたらと思います。 tailwindcssとは tailwindcssとは、CSSフレームワークの一つです。最大の特徴として、ユーティリティファーストなアプローチでデザインを組み立てていく点が挙げられます。 CSS Modulesやstyled-componentでは、デザインをゼロベースで構築することができますが、CSSを一から書かなくてはならず、また、それの運用コストも発生します。 また、BootstrapやMUIなどのCSSフレームワークでは、コンポーネントが事前に用意されているので、効率的にデザイン実装ができる反面、デザインに画一性が生まれてしまいます。 その点、tailwindcssでは事前に提供されている低レベルのユーティリティクラスを用いて、実装速度を担保したまま、独自のデザインを組み立てることができます。 ユーティリティクラスとは tailwindcssでは、m-6, text-whiteなどのクラスをユーティリティクラスと呼んでいます。これら一つ一つのユーティリティクラスには、対応するCSSプロパティが用意されています。 tailwindcssは、MUIなどが提供している事前に用意されたコンポーネントは提供しておらず、プリミティブなCSSプロパティに対応したユーティリティクラスを提供しています。これらをHTMLのclass属性に追加していくことにより、デザインを構築していきます。 実装例 弊チームでは、tailwindcssとCSS Modulesを採用しているので、それらの実装例を見ていきます。 tailwindcssでは、CSSを記述することなく、ユーティリティクラスを用いて、インラインスタイルでデザイン構築を行なっていきます。 一方、CSS Modulesでは、JSXファイルとSCSSファイルをわけて、デザインを構築していきます。 tailwindcssの場合 // 公式サイト(https://tailwindcss.jp/docs/utility-first)から引用 const ChitChat = () =&gt; { return ( &lt; div className = &quot;max-w-sm mx-auto flex p-6 bg-white rounded-lg shadow-xl&quot; &gt; &lt; div className = &quot;flex-shrink-0&quot; &gt; &lt; img className = &quot;h-12 w-12&quot; src = &quot;/img/logo.svg&quot; alt = &quot;ChitChat Logo&quot; &gt; &lt; /div &gt; &lt; div className = &quot;ml-6 pt-1&quot; &gt; &lt; h4 className = &quot;text-xl text-gray-900 leading-tight&quot; &gt; ChitChat &lt; /h4 &gt; &lt; p className = &quot;text-base text-gray-600 leading-normal&quot; &gt; You have a new message ! &lt; /p &gt; &lt; /div &gt; &lt; /div &gt; ) } export default ChitChat ; CSS Modulesの場合 // 公式サイト(https://tailwindcss.jp/docs/utility-first)から引用 import styles from “./chitChat.modules.scss” const ChitChat = () =&gt; { return ( &lt; div className = { styles.chatNotification } &gt; &lt; div className = { styles.chatNotificationLogoWrapper } &gt; &lt; img className = { styles.chatNotificationLogo } src = &quot;/img/logo.svg&quot; alt = &quot;ChitChat Logo&quot; &gt; &lt; /div &gt; &lt; div className = { styles.chatNotificationContent } &gt; &lt; h4 className = { styles.chatNotificationTitle } &gt; ChitChat &lt; /h4 &gt; &lt; p className = { styles.chatNotificationMessage } &gt; You have a new message ! &lt; /p &gt; &lt; /div &gt; &lt; /div &gt; ) } export default ChitChat ; .chatNotification { display : flex ; max-width : 24rem ; margin : 0 auto ; padding : 1.5rem ; border-radius : 0.5rem ; background-color : #fff ; box-shadow : 0 20px 25px -5px rgba( 0 , 0 , 0 , 0.1 ) , 0 10px 10px -5px rgba( 0 , 0 , 0 , 0.04 ) ; } .chatNotificationLogo-wrapper { flex-shrink : 0 ; } .chatNotificationLogo { height : 3rem ; width : 3rem ; } .chatNotificationContent { margin-left : 1.5rem ; padding-top : 0.25rem ; } .chatNotificationTitle { color : #1a202c ; font-size : 1.25rem ; line-height : 1.25 ; } .chatNotificationMessage { color : #718096 ; font-size : 1rem ; line-height : 1.5 ; } なぜtailwindcssを導入したか tailwindcssは、「チームとして新しい技術にチャレンジしたかった」という理由に加え、下記の効果を期待して導入しました。 実装速度の向上 CSS設計と運用コストの削減 スコープが担保した上でのデザイン CSSファイルサイズの最適化 詳細は以下の章で説明させていただきます。 メリット 実装速度が向上する tailwindcssはユーティリティクラスの他にも、hoverなどの容易に適用することができる疑似クラスバリアントや、レスポンシブデザインを容易に実現できる、レスポンシブユーティリティバリアントを提供しています。 以下は一例です。mdをプレフィックスとしてつけることで、指定されたスタイルに「min-width: 768px」のメディアクエリが適用されます。 // 公式サイト(https://tailwindcss.jp/docs/responsive-design)から引用 &lt; div className= &quot;md:flex&quot; &gt; &lt; div className= &quot;md:flex-shrink-0&quot; &gt; &lt; img className= &quot;rounded-lg md:w-56&quot; src = &quot;https://images.unsplash.com/photo-1556740738-b6a63e27c4df?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=crop&amp;w=448&amp;q=80&quot; width = &quot;448&quot; height = &quot;299&quot; alt = &quot;Woman paying for a purchase&quot; &gt; &lt;/ div &gt; &lt; div className= &quot;mt-4 md:mt-0 md:ml-6&quot; &gt; &lt; div className= &quot;uppercase tracking-wide text-sm text-indigo-600 font-bold&quot; &gt; Marketing &lt;/ div &gt; &lt; a href = &quot;#&quot; className= &quot;block mt-1 text-lg leading-tight font-semibold text-gray-900 hover:underline&quot; &gt; Finding customers for your new business &lt;/ a &gt; &lt; p className= &quot;mt-2 text-gray-600&quot; &gt; Getting a new business off the ground is a lot of hard work. Here are five ideas you can use to find your first customers. &lt;/ p &gt; &lt;/ div &gt; &lt;/ div &gt; 最初こそ慣れるまで時間がかかると思いますが、ある程度の書き方を覚えてしまえば、CSSを直接で書くよりも速く実装することが可能だと思います。 CSS設計と運用コストの軽減 tailwindcssを用いると、ユーティリティクラスを直接class属性に記述するだけなので、CSSのクラス設計をする必要がなくなります。CSS設計の規則を考える必要もないですし、それらを基準にして、class名を考える必要もなくなります。 また、CSSファイルを作成する必要がなくなるため、ファイルの運用コストもなくなります。そもそもファイルを作成しないので、CSSが肥大化することもありません。 スコープが担保されるので、安全にデザインを変更できる tailwindcssは基本的にclass属性に記述していくので、HTMLファイルに変更が閉じられ、自然とデザインを適用するスコープが担保されます。 そのため、任意の箇所のデザインを変更することになっても、影響範囲を考えることなく、安全にデザインを変更することができます。 CSSファイルのサイズを小さく出来る tailwindcssはPurgeCSSを採用しており、本番用にビルドする場合には、未使用のスタイルを自動的に削除します。 その結果、プロジェクトで使用しているスタイルのみを出力したCSSファイルが生成され、ビルドサイズが小さくなります。 purgecss.com デメリットと対応方法 慣れるまではクラスを探すのが面倒? tailwindcssのユーティリティクラスに慣れるまでには、CSSプロパティに対応するクラスを探すのに苦労する可能性があります。最初のうちは多少時間を取られると思います。 しかし、エディタのプラグインを使えばある程度負担を軽減することができます。 例えば、VS Codeで提供されているプラグインの「Tailwind CSS InteliSense」を利用すれば、クラス名の自動補完等の機能を使うことができ、比較的開発が楽になるかと思います。 marketplace.visualstudio.com メンテナンス性が落ちる? ユーティリティクラスをクラス属性に書いていくと、多くのクラスがクラス属性内に記述されることになります。結果的に、そのクラスに記述された内容が何をしているかわからない状態になることもあると思います。 その点、tailwindcssはapplyというディレクティブを提供しており、既存のユーティリティクラスを独自のカスタムCSSにインライン化することができます。 tailwindcss.com また、別の方法として、必要な単位でコンポーネントを抽出する方法があります。 tailwindcss.com 個人的には、後者の方法の方がメリットを享受できると考えています。具体的な理由として、Reactをはじめとするコンポーネント志向のフレームワークとの相性が良い点、また無用なユーティリティクラスを増やすことがない点などがあげられます。 提供されていないCSSが必要な場合はどうする? tailwindcssのユーティリティクラスで提供されていないデザインを実装したい場合があるかもしれません。基本的にデフォルトで実装するには十分な量のユーティリティクラスが提供されています。 しかし、万が一足りなくなった場合でも容易に拡張することができます。公式サイトで独自のユーティリティクラスを追加するためのベストプラクティスが紹介されています。 tailwindcss.com 今後の展望 上記メリットでは紹介しなかったのですが、tailwindcssには設定ファイル(tailwind.config.js)が存在します。設定ファイル上で、自分たちのデザインシステムに沿って、tailwindcssをカスタマイズすることができます。以下が設定ファイルの例です。 // Example `tailwind.config.js` file // 公式より引用(https://tailwindcss.jp/docs/configuration) module . exports = { important: true , theme: { fontFamily: { display: [ 'Gilroy' , 'sans-serif' ] , body: [ 'Graphik' , 'sans-serif' ] , } , extend: { colors: { cyan: '#9cdbff' , } , margin: { 96 : '24rem' , 128 : '32rem' , } , } , } , variants: { opacity: [ 'responsive' , 'hover' ] , } , } 上記のように、設定ファイルでデザインをカスタマイズすることが可能なので、tailwindcssはデザインシステムと非常に相性がいいと考えています。 弊チームでは、今後サービスの横展開も考えており、その際にデザインシステムの作成とともに、tailwindcssを利用したUIライブラリを実装していけたらと考えています。 最後に CSSフレームワークにtailwindcssを採用したことで、設計面での負担軽減や、実装速度向上等、開発面で多くのメリットを享受することができました。 今後の展望で記載した通り、個人的にtailwindcssはデザインシステムを組み合わせてこそ真価を発揮します。サービスが展開していくことも考え、tailwindcssをベースとしたUIライブラリを構築していけたらと考えています。今後の取り組みなどについては、また別の機会で知見を共有できたらと思います。 私は現在、新卒2年目なのですが、今回の技術選定を担当させてもらいました。弊社には、意思決定に合理性と納得性があれば年次に関係なく、自由に発言や提案ができる環境があります。 「主体性を持って働きたい人」や「新しい技術にチャレンジしたい人」には最適な環境だと思うので、興味のある方はぜひ一緒に働きましょう。 参考サイト tailwindcss.com
はじめに こんにちは、レバテック開発部の河村です。 私はレバテック各種メディアのリプレイスを担当しており、バックエンドを中心にフルスタック開発を行っています。 今回は管理画面のリリースで採用した、フルスタックフレームワークである frourio について、 frourio を採用した理由や使ってみて良かったこと、困ったことを紹介します。 この記事を通して、frourioのメリット、デメリットだけでなく、レバテック開発部ではどのような背景のもと、技術・アーキテクチャの選定を行っているのか、どれくらいのスピード感で開発を行っているのかをお伝えできればと思います。 なお、この記事では frourio における環境構築や使い方等の説明は割愛させていただきます。 開発背景・経緯 今回、開発する対象となった管理画面は、レバテックの各メディアで運用する記事やセミナー情報、エントリー情報を管理するものになります。 すでに管理画面として、PHP(CakePHP)をベースとした技術で作られているものを利用している状態でしたが、 PHP自体のバージョンも古く、利用しているライブラリなどを含めレガシーな状態になっており、開発速度、品質、運用、保守性が低下している状態でした。 また、 レバテック組織全体でTypeScriptの標準化 を進めており、管理画面のリニューアルが必須の状態でした。 管理画面のため、特別凝ったデザイン等は必要ありませんが、利用するロールやメンバーが多く、複雑な機能が必要となることもあり、管理画面のフレームワーク(ReactAdminやAdminBroなど)を利用してしまうと、機能の実装が困難になる可能性もあり、基本的には 拡張性が高い状態、フルスクラッチで開発できる環境 を構築する必要がありました。 さらに、事業側の方々の要望もあり、約1か月程度での本番リリース、稼働開始が必要だったため、 上記の背景、経緯を踏まえた上で 開発速度 が出せるようなフレームワークを選定する必要がありました。 このような背景、経緯があり、frourioを採用することにしました。 frourioを採用した理由については後ほど詳細を説明させていただきます。 frourioとは frourioは一言で言うと、TypeScript製のフルスタックWebフレームワークで、詳細については、 公式ドキュメント または Github を参照してください。 frourioの特徴 frourioの1番の特徴は、 フロントエンドからバックエンドまですべてTypeScriptでかつ型安全に開発できる ところです。 以下の図が、frourioが内包しているフレームワークやORMになります。 上記の図の通り、frourioはTypeScriptをベースに利用できるフレームワークを内包しています。 フロントエンドだとNext.jsやNuxt.jsを選択できたり、ORMだとTypeORMやPrismaを利用することができます。 また、Node.js系のフレームワークでハイパフォーマンスと言われているfastifyも利用することができ、安定したパフォーマンスを出すことも期待できます。 その他にも以下のような特徴がありますが、本記事では割愛させていただきますので、気になる方はぜひ調べてみてください。 コマンド1発でフロントエンドからバックエンドの環境構築 Vercel社が開発しているReact Hooksライブラリ「SWR」に対応 関数ベースでControllerを実装可能 関数ベースでDI(Dependency Injection)の実現 frourioを採用した理由 frourioを採用した理由として、大きく以下の3つになります。 TypeScriptを利用して型安全に開発できる チームとして利用したいフレームワークが内包されている 自動生成により開発速度を大きく向上させることができる 上記の理由について、以下、詳細に説明させていただきます。 TypeScriptを利用して型安全に開発できる frourioの特徴でもお伝えしましたが、frourioはフロントエンドからバックエンドまですべてTypeScriptでかつ型安全に開発することができます。型安全に開発することで、不正な動作を事前に防げたり、フロントエンドとバックエンドをつなぐ部分のInterfaceを明示的に宣言でき、フロントエンドからバックエンドのAPIを呼び出す際も安全にかつスムーズに呼び出すことができます。 例えば、以下のように、バックエンドのAPIのInterfaceを定義し、 export type Task = { id: number label: string done: boolean } export type Methods = { post: { reqBody: Pick &lt; Task , 'label' &gt; resBody: Task } } フロントエンドからInterfaceを参照しながら、安全にかつスムーズにAPIを呼び出すことができます。 await apiClient.tasks.post ( { body: { label } } ) ここで、バックエンドのAPIのInterfaceを定義しましたが、 frourioのルールにしたがって定義することで、 ApiClient(APIのクライアント)部分を自動生成 してくれます。 こちらの自動生成については、 自動生成により開発速度を大きく向上させることができる の箇所で説明したいと思います。 チームとして利用したいフレームワークが内包されている チームとして、以下のフレームワークを利用して開発工数をできる限り抑えることを目指していました。 フロントエンド Nuxt.js Next.js バックエンド TypeORM まず、フロントエンドですが、frourioではNuxt.js、Next.jsともに利用することができます。 チーム内ではVue.js、Nuxt.jsの方が経験のあるメンバーが多いため、どちらを選定するかで迷いましたが、TypeScriptとの相性、チームメンバーのスキル向上、モチベーションアップを考慮した上で、Next.jsを選定しました。型安全に開発できることをメインに置いていることもあり、TypeScriptとの相性の良いReact.jsをベースとしたNext.jsを選定することが当然であるかもしれませんが、それ以上に、チーム内でReact.js、Next.jsの技術を身につけていきたいというモチベーションが選定において大きな判断材料になったかと思います。 ちらっと、TypeScriptとの相性について述べましたが、こちらの記事が参考になりますので、気になる方は参考にしてみてください。 完全に独断と偏見だけどReact vs Vue してみた また、バックエンド、主にORMになりますが、 TypeORM を採用しました。 現在、レバテック全体で各種機能のマイクロサービス化が進んでおり、TypeORMを利用しているチームやメンバーが多いことから選定しました。個人的にはPrismaを使ったみたいという気持ちもありましたが、開発工数をできる限り抑えるということで、すでに知見が溜まっているTypeORMを選定しました。 これにより、バックエンドの開発は、今までとほぼ変わらず、frourioのお作法を覚えるだけで開発できる環境を整えることができました。 余談:CSSフレームワーク フルスクラッチで開発する以上、UIを作成するために、管理画面のフレームワーク(ReactAdminやAdminBro等)と比べて、UIの実装にある程度の工数がかかってしまいます。なので、そこで、CSSフレームワークでReactと相性の良い Material-UI を採用しています。 さらに、管理画面においては、同じような画面、Componentの使い回しが多くなることから、 Atomic Design の概念を採用して、再利用される可能性のあるComponentはできる限り共通化(atoms, moleculed, organismsを適用)し、UIの実装にかかる工数をできる限り削減しました。 自動生成により開発速度を大きく向上させることができる frourioのお作法にしたがって開発することで、様々な部分を自動生成をしてくれて、開発工数を大きく削減でき、開発速度を向上させることができます。個人的に、特にフロントエンドとバックエンドをつなぐ部分、ApiClient(APIのクライアント)部分の自動生成がとても便利だったので、紹介させていただきます。 ディレクトリを作成するだけで、Controllerを自動作成してくれる APIのInterfaceを定義するだけで、ApiClientを自動生成してくれる ディレクトリを作成するだけで、Controllerを自動作成してくれる frourioの機能を利用することで、バックエンドのController部分に該当する ./server/api ディレクトリに対象のエンドポイント用のディレクトリを作成するだけで、必要なファイルを生成してくれます。 上記の機能を説明する前に、まず、frourioを使って環境構築した後のディレクトリ構成を紹介します。 frourioのディレクトリ構成 frourioのコマンドを使って環境構築を行った際の設定が以下になります。 Next.jsのcreate-next-appのようなコマンドがfrourioにもあるので環境構築は簡単にできます。 frourio環境構築時の設定1 frourio環境構築時の設定2 上記を行った結果、以下のようなディレクトリ構成が出来上がります。 . ├── README.md ├── aspida.config.js ├── components # React用のComponent ├── ecosystem.config.js ├── jest.config.ts ├── next-env.d.ts ├── package.json ├── pages # Next.jsのpagesに該当 ├── public # フロントエンド用の静的ファイル ├── server # バックエンド │ ├── $orm.ts │ ├── $server.ts │ ├── api # バックエンドのController部分に該当 │ ├── entity # TypeORMのEntity │ ├── entrypoints # バックエンドのサーバーを立ち上げるための設定等 │ ├── index.js │ ├── migration # TypeORMによって生成されたMigrationファイル │ ├── ormconfig.ts │ ├── package.json │ ├── service # レイヤードアーキテクチャ的に言うと、アプリケーションサービスに該当 │ ├── static # バックエンド用の静的ファイル │ ├── subscriber # TypeORMのSubscriber │ ├── test # バックエンド用のテスト │ ├── tsconfig.json │ ├── types # バックエンド用の型定義ファイル │ ├── validators # Service等で利用したいValidation用のDTOに該当 │ ├── webpack.config.js │ └── yarn.lock ├── styles # CSS ├── test # フロントエンド用のテスト ├── tsconfig.json ├── utils # フロントエンド用のUtil │ └── apiClient.ts └── yarn.lock 正直、利用しないディレクトリ等も生成されてしまいますが、不要なものは後から取り除く形で問題ないかと思います。 (実際に、 ./styles や ./server/service 、 ./server/subscriber は不要だったので取り除いています) また、1つのディレクトリ内でフロントエンド、バックエンドを管理しているために、一見モノリシックに見えますが、Interface(型定義)で繋がっていること以外は、フロントエンド、バックエンド、それぞれに別のpackage.jsonがあり、別のプロジェクトとして扱うことができます。ライブラリ等が依存し合ったりすることもなく、仮にバックエンドを別のものに差し替えたとしても、フロントエンドのAPIの呼び出し部分を変更するだけで可能になります。 ./server/api ディレクトリにディレクトリを作成してみると 上記のディレクトリ構成の通り、 ./server/api ディレクトリがバックエンドのController部分に該当します。 補足ですが、frourioのコマンドで環境構築をすると、デフォルトでサンプルのコードが作成されます。 また、 $ がつくファイルはfrourio側で生成されるファイルになります。 frourioの自動生成機能1 こちらのディレクトリに、 hogehoge ディレクトリを作成して、バックエンド側をビルドすると以下のようになります。 ホットリロードでも生成されるので、実際にはサーバーを起動したまま作成していくことになるかと思います。 frourioの自動生成機能2 上記のようにファイルが生成されており、実際のファイルの中身は以下のようになります。 メソッド自体はサンプル的なものになっていますが、これだけでも自動生成してくれるだけで恩恵を得られるかと思います。 index.ts export type Methods = { get : { resBody: string } } controller.ts import { defineController } from './$relay' export default defineController (() =&gt; ( { get : () =&gt; ( { status : 200 , body: 'Hello' } ) } )) $relay.ts /* eslint-disable */ // prettier-ignore import { Injectable , depend } from 'velona' // prettier-ignore import type { FastifyInstance , onRequestHookHandler , preParsingHookHandler , preValidationHookHandler , preHandlerHookHandler } from 'fastify' // prettier-ignore import type { Schema } from 'fast-json-stringify' // prettier-ignore import type { HttpStatusOk } from 'aspida' // prettier-ignore import type { ServerMethods } from '../../$server' // prettier-ignore import type { Methods } from './' // prettier-ignore type Hooks = { onRequest?: onRequestHookHandler | onRequestHookHandler [] preParsing?: preParsingHookHandler | preParsingHookHandler [] preValidation?: preValidationHookHandler | preValidationHookHandler [] preHandler?: preHandlerHookHandler | preHandlerHookHandler [] } // prettier-ignore type ControllerMethods = ServerMethods &lt; Methods &gt; // prettier-ignore export function defineResponseSchema &lt; T extends { [ U in keyof ControllerMethods ] ?: { [ V in HttpStatusOk ] ?: Schema }} &gt;( methods: () =&gt; T ) { return methods } // prettier-ignore export function defineHooks &lt; T extends Hooks &gt;( hooks: ( fastify: FastifyInstance ) =&gt; T ) : ( fastify: FastifyInstance ) =&gt; T // prettier-ignore export function defineHooks &lt; T extends Record &lt; string , any &gt;, U extends Hooks &gt;( deps: T , cb: ( d: T , fastify: FastifyInstance ) =&gt; U ) : Injectable &lt; T , [ FastifyInstance ] , U &gt; // prettier-ignore export function defineHooks &lt; T extends Record &lt; string , any &gt;&gt;( hooks: ( fastify: FastifyInstance ) =&gt; Hooks | T , cb?: ( deps: T , fastify: FastifyInstance ) =&gt; Hooks ) { return cb &amp;&amp; typeof hooks !== 'function' ? depend ( hooks , cb ) : hooks } // prettier-ignore export function defineController ( methods: ( fastify: FastifyInstance ) =&gt; ControllerMethods ) : ( fastify: FastifyInstance ) =&gt; ControllerMethods // prettier-ignore export function defineController &lt; T extends Record &lt; string , any &gt;&gt;( deps: T , cb: ( d: T , fastify: FastifyInstance ) =&gt; ControllerMethods ) : Injectable &lt; T , [ FastifyInstance ] , ControllerMethods &gt; // prettier-ignore export function defineController &lt; T extends Record &lt; string , any &gt;&gt;( methods: ( fastify: FastifyInstance ) =&gt; ControllerMethods | T , cb?: ( deps: T , fastify: FastifyInstance ) =&gt; ControllerMethods ) { return cb &amp;&amp; typeof methods !== 'function' ? depend ( methods , cb ) : methods } 追加したControllerの場合でも、開発側は特別な設定を追加することなく、frourio側が自動に設定してくれます。 実際には、 ./server/$server.ts に自動で設定されますが、ファイルがけっこうな行数になってしまうので、ここでは割愛させていただきます。 APIのInterfaceを定義するだけで、ApiClientを自動生成してくれる frourioでは、標準でTypeScript製のREST APIクライアントである aspida を利用して、バックエンドのAPIにアクセスすることができます。そのため、フロントエンド側に ./utils/apiClient.ts が生成され、 aspida の設定が追加されています。 ├── utils # フロントエンド用のUtil │ └── apiClient.ts apiClient.ts import aspida from '@aspida/axios' import api from '~/server/api/$api' export const apiClient = api ( aspida ()) 上記の通り、 ~/server/api/$api.ts を読み込んでいるのですが、 こちらがバックエンド側で定義したAPIのInterfaceを元に、自動生成されたClientの接続周りをラップしたものになります。 例えば、上記の例(hogehoge)の場合だと、 ~/server/api/$api.ts は以下のように生成されます。 /* eslint-disable */ // prettier-ignore import { AspidaClient , BasicHeaders , dataToURLString } from 'aspida' // prettier-ignore import { Methods as Methods0 } from '.' // prettier-ignore import { Methods as Methods1 } from './hogehoge' // prettier-ignore const api = &lt; T &gt;( { baseURL , fetch } : AspidaClient &lt; T &gt;) =&gt; { const prefix = ( baseURL === undefined ? 'http://localhost:39809/api' : baseURL ) .replace ( /\/$/ , '' ) const PATH0 = '/hogehoge' const GET = 'GET' const POST = 'POST' const DELETE = 'DELETE' const PATCH = 'PATCH' return { hogehoge: { get : ( option?: { config?: T } ) =&gt; fetch &lt; Methods1 [ 'get' ][ 'resBody' ] &gt;( prefix , PATH0 , GET , option ) .text (), $get: ( option?: { config?: T } ) =&gt; fetch &lt; Methods1 [ 'get' ][ 'resBody' ] &gt;( prefix , PATH0 , GET , option ) .text () .then ( r =&gt; r.body ), $path: () =&gt; ` ${ prefix }${ PATH0 } ` } } // prettier-ignore export type ApiInstance = ReturnType &lt;typeof api &gt; // prettier-ignore export default api これらのfrourioの機能を利用することで、フロントエンドは特段、APIを呼び出すためにAPI用のClientを実装する必要がなくなります。バックエンドのAPIを実装した時点で、フロントエンドはAPIを呼び出して開発を進めることができます。 frourioを使って困ったこと 現段階でfrourioを使って困ったことをいくつか紹介させていただきます。 ディレクトリ構成が変更しづらい 不要な設定が思ったより追加されてしまう ディレクトリ構成が変更しづらい frourioのお作法にしたがって開発することで、様々な部分を自動生成をしてくれる分、ある程度、frourioが用意したディレクトリ構成に従わないと動作しない機能がいくつかあります。ただし、ORMに関する設定はよしなに変更できたりします(例えば、TypeORMであれば、 ormconfig.ts の設定を変更したりなど) バックエンドを簡単なレイヤードアーキテクチャの構成(UI層→Application層→Domain層→Presentation層)にしようと思っても、上記で紹介した ./server/api ディレクトリを変更するとControllerを追加できなかったり、 ./server/validators ディレクトリをApplication層のDTOとして利用しようと思っても、 ./server/validators ディレクトリ内に作成しないと class-validator が機能しなかったりします。 これらの原因は明確で、frourioが生成される $ が付いたファイルをよしなに書き換えたりすることができないためです。 例えば、validatorsであれば server/$server.ts に自動で設定が作成され、以下のように設定されています。 $server.ts(一部抜粋) ... // prettier-ignore import * as Validators from './validators' ... // prettier-ignore const createValidateHandler = ( validators: ( req: FastifyRequest ) =&gt; ( Promise &lt; void &gt; | null ) [] ) : preValidationHookHandler =&gt; ( req , reply ) =&gt; Promise . all( validators ( req )) . catch ( err =&gt; reply.code ( 400 ) .send ( err )) // prettier-ignore const formatMultipartData = ( arrayTypeKeys: [ string , boolean ][] ) : preValidationHookHandler =&gt; ( req , _ , done ) =&gt; { const body: any = req.body for ( const [ key ] of arrayTypeKeys ) { if ( body [ key ] === undefined ) body [ key ] = [] else if ( ! Array .isArray ( body [ key ] )) { body [ key ] = [ body [ key ]] } } Object .entries ( body ) .forEach (( [ key , val ] ) =&gt; { if ( Array .isArray ( val )) { body [ key ] = ( val as Multipart [] ) .map ( v =&gt; v.file ? v : ( v as any ) .value ) } else { body [ key ] = ( val as Multipart ) .file ? val : ( val as any ) .value } } ) for ( const [ key , isOptional ] of arrayTypeKeys ) { if ( ! body [ key ] .length &amp;&amp; isOptional ) delete body [ key ] } done () } 上記の通り、frourioの機能を存分に活用するには、frourioのルールにある程度従う必要があります。 ただ、frourioの機能、特に自動生成周りの機能は、開発スピード、コストにおいてとても効果を発揮しますが、バックエンドをきれいなアーキテクチャで作成したいようなニーズとはマッチしないかと思います。特に、今回はある程度簡単な機能が多いということもありfrourioを採用しているので、無理してアーキテクチャを整える必要はなく、特に大きな問題とはなっていません。 必要のない設定が思ったより追加されてしまう frourioの公式に従って環境構築を進めると、たしかにコマンド1発でフロントエンドからバックエンドの環境構築ができますが、その分、様々な設定が追加されている状態になります。不必要な設定があると開発者内で認識齟齬につながる可能性があるので、開発において不必要な設定は取り除いておいたほうがいいかと思います。 例えば、バックエンドのサーバーを立ち上げるための設定をしている server/entrypoints/index.ts では、デフォルトの状態だと以下のようになっています。 index.ts import 'reflect-metadata' import path from 'path' import Fastify from 'fastify' import helmet from 'fastify-helmet' import cors from 'fastify-cors' import fastifyStatic from 'fastify-static' import fastifyJwt from 'fastify-jwt' import { createConnection } from 'typeorm' import server from '$/$server' import ormOptions from '$/$orm' import { API_JWT_SECRET , API_SERVER_PORT , API_BASE_PATH , API_UPLOAD_DIR , TYPEORM_HOST , TYPEORM_USERNAME , TYPEORM_PASSWORD , TYPEORM_DATABASE , TYPEORM_PORT } from '$/service/envValues' const fastify = Fastify () fastify.register ( helmet ) fastify.register ( cors ) fastify.register ( fastifyStatic , { root: path.join ( __dirname , 'static' ), prefix: '/static' } ) if ( API_UPLOAD_DIR ) { fastify.register ( fastifyStatic , { root: path.resolve ( __dirname , API_UPLOAD_DIR ), prefix: '/upload' , decorateReply: false } ) } fastify.register ( fastifyJwt , { secret: API_JWT_SECRET } ) server ( fastify , { basePath: API_BASE_PATH } ) createConnection ( { type : 'mysql' , host: TYPEORM_HOST , username: TYPEORM_USERNAME , password: TYPEORM_PASSWORD , database: TYPEORM_DATABASE , port: Number ( TYPEORM_PORT ), migrationsRun: true , synchronize: false , logging: false , ...ormOptions } ) .then (() =&gt; fastify.listen ( API_SERVER_PORT )) また、デフォルトの状態の環境変数は以下のようになります。 .env API_SERVER_PORT=39809 API_BASE_PATH=/api API_ORIGIN=http://localhost:39809 API_JWT_SECRET=supersecret API_USER_ID=id API_USER_PASS=pass API_UPLOAD_DIR=upload TYPEORM_HOST=localhost TYPEORM_USERNAME=root TYPEORM_PASSWORD= TYPEORM_DATABASE=hogehoge TYPEORM_PORT=3306 おそらく、初期段階ではファイルのアップロードの設定や認証・認可の設定は不要になるかと思います。逆に、必要になるCORSやTypeORMの設定がされているので、この部分は工数削減になります。ただ、このように様々な箇所にデフォルトで設定がされているので、不要な設定がないかは確認しておいたほうが良いかと思います(他にも、TypeORMの場合、デフォルトでSubscriberの設定が追加されています) 最後に 管理画面の開発にfrourioを採用し、必要最低限のみの機能にはなりますが、想像以上のスピードでリリースすることができました。 ですが、まだまだfrourioの機能を活かせていない部分があり、特に aspida 、 SWR(stale-while-revalidate) 、validationの機能を拡充したり、機能、非機能要件として以下の機能を実装していく必要があります。 NextAuthを利用した認証・認可 レバテック内部の各マイクロサービスとの連携 メール配信、予約配信 記事投稿のためのリッチテキストエディター ...etc 今回の記事で説明しきれていないfrourioの特徴やfrourio以外で困ったこと(Next.js、TypeORM固有の課題など)、frourioを利用したシステムアーキテクチャ(AWS)、また、今後上記の機能を実装していくなかで得られた知見などをまた別の機会に共有できたらなと思います。 参考記事 憧れのTypeScriptフルスタック環境がコマンド1発で作れる超軽量フレームワーク「frourio」 TS製フルスタックフレームワークfrourioの入門会を主催しました! frourio でフロントエンドとバックエンドを一緒に静的型検査する
対象読者 マイクロサービス化を検討しており、実際に作る場合の構成を参考にしたい。 ドメイン駆動設計について、基本的な用語の知識がある。 TypeScript を多少触ったことがある。理解がある。 はじめに こんにちは。エンジニアの吉村です。 現在、弊社が運営する teratail というサービスに携わっており、CakePHP で動作しているモノリシックな既存サービスをマイクロサービスに移行するというプロジェクトを進行中です。 この記事では、実務を通して得た知見として、マイクロサービス化によりどんな恩恵があるのか、具体的にどのような構成で実装をしているのかについてご紹介します。 TL;DR マイクロサービスのバックエンドサービスの実装に焦点を絞って、ドメイン駆動設計 + オニオンアーキテクチャをベースに設計をしました。 本記事では、具体的に「ユーザ新規登録処理」の実装をする場合を例にとり、実装手順をなぞりながら詳細を解説しています。 結論としては、従来の MVC ベースのアーキテクチャと比較して、メンテナンス性が高く技術的負債が生じづらい、スケーリングが容易な構成にできたのではないかと思います。その反面、簡単な処理の実装であっても記述量が多くなってしまうため、初期コストは割と高めな構成であるように思いました。 なぜマイクロサービス化を導入したのか モノリシックサービスとマイクロサービスとで、どちらの構成が優れているかについては、実装するサービスの規模や様々な背景などによって左右される部分ではありますが、 今回のケースでは 比較的規模の大きいシステムであること サービスの改善を更に速度を上げて実施すべく、スケールしやすい構成にしておく必要があること 弊社の他マイクロサービスと相互連携をしやすい構成にする必要があったこと などの理由から、マイクロサービスとしてリプレースをしていく方針となりました。 マイクロサービスでシステムを実装をしていくにあたって、初期設計の段階で失敗してしまうとメンテナンスが困難なシステムができあがってしまう恐れがあるため、アーキテクチャの検討は注意深く行っていく必要がありました。 様々な文献で構成を調べる上で、特に気をつけたポイントは、なるべく技術的負債を回避でき、処理の把握が容易でメンテナンス性が高く、ミドルウェア等の環境の変更に強いシステムとなることでした。 それら検討の上で現在どのような構成で実装をしているか、簡単な処理での例を基にご紹介します。 サービスの構成 サービス全体の構成としては、フロントエンドサーバ、BFFサーバ、バックエンドサーバ と分けて実装をしていますが、 今回はバックエンドサーバのサービス構成に焦点を絞ってご紹介していきます。 構成図 サービスの構成は、オニオンアーキテクチャ[1]を参考に、以下の図の構成としています。 サービスの根幹となるドメイン層を中心に置いて、アプリケーション層、アダプタ層、インフラストラクチャ層と順に囲っていき、 外側の層から内側の層への単方向の依存のみ許可しています。※図の右側の各モジュールの詳細については後述します。 マイクロサービス化をするにあたり、ドメイン駆動設計を参考に、一つのアプリケーションを、例えばユーザ のアカウントについて管理する user サービスや、投稿された内容を管理する post サービスなど、それぞれ独立して成立するサービスとして分割していきます。このときに分割されたサービスの粒度を1単位としてサブドメインと呼ぶことにし、サブドメインごとに上図の構成のサービスを実装していきます。 ディレクトリ構成 ディレクトリの基本的な構成は以下の通りです。 以下の例ではサブドメインが user のみの構成となっていますが、サブドメインの数だけここに同様の構成のものが並びます。 src/shared には共通のコアロジックや、ベースクラスなどを配置しています。 src ├── server.ts ├── subdomains │  └── user │      ├── Entities │      │   └── User.ts │      ├── Events │      │   └── UserCreated.ts │      ├── Infrastructures │      │   └── typeorm │      │   └── User.ts │      ├── Mappers │      │   └── UserMap.ts │      ├── Repositories │      │   ├── UserRepository.ts │      │   └── implements │      │      └── TypeOrmUserRepository.ts │      ├── Subscriptions │      │   └── AfterUserCreated.ts │      ├── UseCases │      │   └── CreateUser │      │      ├── CreateUserUseCase.ts │      │      ├── CreateUserDTO.ts │      │      ├── CreateUserErrors.ts │      │      └── CreateUserController.ts │      └── ValueObjects │      └── UserName.ts └── shared ├── core │ ├── AppError.ts │ ├── Guard.ts │ ├── Result.ts │ ├── UseCase.ts │ └── UseCaseError.ts └── domain ├── events │ ├── DomainEvents.ts │ ├── IDomainEvent.ts │ └── IHandle.ts ├── AggregateRoot.ts ├── Entity.ts ├── Identifier.ts ├── UniqueEntityID.ts ├── ValueObject.ts └── WatchedList.ts 実際に「ユーザ登録処理」を作ってみる ドメイン層の定義 まず、ドメイン層として ValueObject と Entity の定義をしていきます。 ドメイン層はサービスの根幹となる部分であり、すべての処理はこのドメイン層で定義されたものを操作する形で実装していきます。 ValueObject ValueObject は、Entityを構成するプロパティとして使用したり、レイヤ間のやり取りをする箇所などで用います。 ValueObject を用いるメリットとしては、対象の値が正しい形であるということが担保できるという点と、その値に対してのビジネスルールを豊かに表現できるという点があります。 オブジェクトを生成する際に必ずバリデーションロジックを通すように作ることができるため、ValueObject としてデータが存在している時点で、形式的に正しい値を持っているということが保証され、様々な箇所で形式チェックをする必要がありません。 また、例えば Email という ValueObject を作成したときに、対象の文字列からドメイン部分のみ抽出するというメソッドを用意したりなど、対象の値に対して何らかの加工をした形で取得をするといったことをまとめて定義しておくことができます。 以下の例では、ユーザの名前を表現する UserName ValueObject を定義しています。 入力された値が 3 文字以上 32 文字以下の場合はエラーを返す、簡単なバリデーションロジックを組み込んでいます。 // src/subdomains/user/ValueObjects/UserName.ts export interface UserNameProps { value: string } export class UserName extends ValueObject &lt; UserNameProps &gt; { get value () : string { return this .props.value } private constructor( props: UserNameProps ) { super( props ) } private static isValidName ( name: string ) { const re = /^.{3,32}$/ return re.test ( name ) } public static create ( name: string ) : Result &lt; UserName &gt; { if ( ! this .isValidName ( name )) { return Result.fail &lt; UserName &gt;( `Invalid Argument - userName:[ ${ name } ]` ) } return Result.ok &lt; UserName &gt;( new UserName ( { value: name } ) ) } } ※ Result Result クラスは、成功形か失敗形かの状態を持たせた結果データを表すものとして実装しています。 成功形の場合は成功した値を取得することができ、失敗形の場合はエラーレスポンスを取得できます。どちらの状態であるかは isSucceed と isFailure のプロパティの値で判別可能です。 上記 ValueObject の生成メソッドでこの戻り値を利用していますが、状態ごとの処理を記述する可能性のあるあらゆる箇所で利用しています。 Entity 用意した ValueObject をプロパティとして設定し、Entity を定義します。 Entity は固有の識別子として id を持っており、 id が一致している Entity は、プロパティが異なっている場合でも同一のものとして扱います。 // src/subdomains/user/Entities/User.ts interface UserProps { name: UserName createdAt: DateTime updatedAt: DateTime deletedAt?: DateTime } export class User extends Entity &lt; UserProps &gt; { get name () : UserName { return this .props.name } get createdAt () : DateTime { return this .props.createdAt } get updatedAt () : DateTime { return this .props.updatedAt } get deletedAt () : DateTime | undefined { return this .props.deletedAt } private constructor( props: UserProps , id?: UniqueEntityID ) { super( props , id ) } public static create ( props: UserProps , id?: UniqueEntityID ) : Result &lt; User &gt; { const user = new User ( { ...props } , id ) return Result.ok &lt; User &gt;( user ) } } アプリケーション層の実装 次に、定義されたドメイン層の部品を操作して、ユーザ作成処理を記述するアプリケーション層の UseCase、 DTO、 RepositoryInterface を実装していきます。 DTO まず、 DTO(Data Transfer Object)を定義します。 DTO は、UseCase の入出力パラメータを表します。 UseCase がリクエストとしてどういう形式を求めているか、レスポンスとしてどんなデータが返るのかを明確に定義しておくことで UseCase の仕様を把握しやすくするなる他、UseCase を実行する際にはこの定義に従って呼ぶようにすればよいため、 通信プロトコルが何なのか、または DomainEvent (後述します)の後続処理として実行されるのかなど、実行元が何であるかなどを意識することなく UseCase を定義することができるようになります。 // src/subdomains/user/UseCases/CreateUser/CreateUserDTO.ts export interface CreateUserRequestDTO { name: string } export interface CreateUserResponseDTO { success: boolean } UseCase UseCase は、ドメイン層を操作してデータの加工をし、Repository にデータを渡す処理や、Repository から取得したデータを返却する処理を記述していきます。 UseCase は、できるだけ単一の操作にのみ責任を持つように作るほうが良いです。例えば、エンティティを新規作成する UseCase を作成する場合は、データ作成の成否のみを(必要であれば作成したエンティティのIDのみあわせて)返却するよう実装し、エンティティ全体のデータを取得する UseCase は別途作成するようにしておきます。 このように、データの操作のみに責任をもつ Command 処理と、データの返却にのみ責任を持つ(データを操作しない) Query 処理に明確に分けて実装しておくと、それぞれの処理の最適化に注力しやすくなり、データの読み出しに関してはデータに副作用を与えないことが担保された Query 処理を使うことで、安全にシステムを利用することができるようになります。※CQRS(コマンドクエリ責務分離)[2] 以下の例では、ユーザデータを作成を実行して、成功か失敗かの bool 値のみを返す Command UseCase を実装しています。 // src/subdomains/user/UesCase/CreateUser/CreateUserUseCase.ts type Response = Either &lt; | CreateUserAlreadyExistsError | CreateUserFailedToCreateUserError | UnexpectedError | BadRequestError , Result &lt; CreateUserResponseDTO &gt; &gt; export class CreateUserUseCase implements UseCase &lt; CreateUserRequestDTO , Promise &lt; Response &gt;&gt; { constructor( private userRepository: UserRepository ) {} async execute ( req: CreateUserRequestDTO ) : Promise &lt; Response &gt; { const nameOrError = UserName.create ( req.name ) if ( nameOrError.isFailure ) { return failed (new BadRequestError ( nameOrError.errorValue () .value )) } const name = nameOrError.getValue () const now = DateTime.utc () try { if (await this .userRepository.isExists ( name )) { return failed (new CreateUserAlreadyExistsError ( name )) } const userOrError = User.create ( { name: name , createdAt: now , updatedAt: now } ) if ( userOrError.isFailure ) { return failed (new CreateUserFailedToCreateUserError ()) } const user = userOrError.getValue () await this .userRepository.save ( user ) return succeed ( Result.ok &lt; UserCreateResponse &gt;( { success: true , } ) ) } catch ( err ) { return failed (new UnexpectedError ( err.message )) } } } ※ Either UseCase のレスポンスは、Either クラスで表現しています。 UseCase の結果値として、複数の Result オブジェクトが返る場合がありますが、それぞれが正常系か異常系かを振り分けて定義しています。 これにより、 UseCase の利用側はレスポンスを判別が成功系か異常系か判別して処理を記述しやすくなるほか、UseCase の定義書のように機能し、どんなパターンのレスポンスが存在するか一目で分かるようになる利点があります。 UseCaseError UseCase の異常系レスポンスとして、UseCaseError を定義します。 // src/subdomains/user/UesCase/CreateUser/CreateUserError.ts export class CreateUserAlreadyExistsError extends Result &lt; UseCaseError &gt; { constructor( name: UserName ) { super( false , { message: `User name already exists. - name: ${ name.value } ` } as UseCaseError ) } } export class CreateUserFailedToCreateUserError extends Result &lt; UseCaseError &gt; { constructor() { super( false , { message: &quot;Failed to create user.&quot; } as UseCaseError ) } } RepositoryInterface UseCase 実装時点で、Repository にアクセスする必要がある箇所を記述する場合は、Repository の実装を抽象化した RepositoryInterface を作成し、抽象化された Repository へアクセスする形で実装していきます。※依存性逆転の原則[3] これをする理由は、UseCase は本来、データを永続化する仕組みが何であるか、ORMとして何を使っているのかなどは意識する必要がなく、永続化データへの読み書きさえできれば Repository の具体的な実装を気にしなくても良いためです。 また、Repository を抽象化せずに具体実装に対してアクセスするようにしてしまうと、アプリケーション層がそれより外側のアダプタ層に依存する形での実装となってしまい、内側の層が外側の層に依存してはならないというルールに反してしまうため、これを逆転させる目的があります。 // src/subdomains/user/Repositories/UserRepository.ts export interface UserRepository { isExists ( name: UserName ) : Promise &lt; boolean &gt; save ( user: User ) : Promise &lt; void &gt; } データ永続化処理実装 UseCase 実装の際に定義した RepositoryInterface の定義に基づき、DB 定義とデータ永続化の具体実装をしていきます。 従来型の MVC による実装などに慣れていると、まず DB の定義をしてから UseCase 処理の実装をしていきたくなるかもしれませんが、今回のアーキテクチャではそれとは逆で、ドメイン層とアプリケーション層を実装したあとに DB 定義を行います。 Repository は アプリケーション層・ドメイン層の処理の中で生成・更新された Entity を永続化し、永続化されたデータを Entity として返すことにのみ責任を持つように実装していきます。 こうすることでサービスのコアとなるロジックが DB の定義や ORM などに密接に依存してしまうような作りを回避することができます。 DBスキーマ定義 TypeORM を使用する場合を例に以下のようにモデルを定義します。 // src/subdomains/user/Infrastructures/typeorm/User.ts import { Column , CreateDateColumn , DeleteDateColumn , Entity , PrimaryGeneratedColumn , UpdateDateColumn } from &quot;typeorm&quot; ; @Entity () export class User { @PrimaryGeneratedColumn () id: number ; @Column () name: string ; @CreateDateColumn ( { type : &quot;datetime&quot; , } ) createdAt ! : Date @UpdateDateColumn ( { type : &quot;datetime&quot; , } ) updatedAt ! : Date @DeleteDateColumn ( { type : &quot;datetime&quot; , nullable: true , } ) deletedAt?: Date } Repository 実装 // src/subdomains/user/Repositories/implements/TypeOrmUserRepository.ts import { createConnection , getRepository } from 'typeorm' import { User } from '../../Infrastructures/typeorm/User' import { UserMap } from '../../Mappers/UserMap' export class TypeOrmUserRepository implements UserRepository { async isExists ( name: UserName ) : Promise &lt; boolean &gt; { const connection = await createConnection () const userRepository = getRepository ( User ) const user = await userRepository.findOne ( { name } ) await connection.close () return !! user } async save ( user: User ) : Promise &lt; void &gt; { const connection = await createConnection () const userRepository = getRepository ( User ) await userRepository.save ( UserMap.toPersistent ( user )) await connection.close () } } Mapper 実装 Entity &lt;=> 永続化データ間の変換処理を Mapper として実装します。 // src/subdomains/user/Mappers/UserMap.ts export interface UserRawProps { id: string name: string createdAt: Date updatedAt: Date deletedAt?: Date } export class UserMap implements Mapper &lt; User &gt; { public static toDomain ( raw: UserRawProps ) : User { const userNameOrError = UserName.create (new UniqueEntityID ( raw.name )) if ( userNameOrError.isFailure ) { throw new Error ( userNameOrError.error ) } const userOrError = User.create ( { name: userNameOrError.getValue (), createdAt: DateTime.fromJSDate ( raw.createdAt ), updatedAt: DateTime.fromJSDate ( raw.updatedAt ), deletedAt: raw.deletedAt ? DateTime.fromJSDate ( raw.deletedAt ) : undefined } , new UniqueEntityID ( raw.id ) ) if ( userOrError.isFailure ) { throw new Error ( userOrError.error ) } return userOrError.getValue () } public static toPersistent ( userEntity: User ) : UserCreationParam { return { id: userEntity.id.toString (), name: userEntity.name.value , createdAt: userEntity.createdAt.toJSDate (), updatedAt: userEntity.updatedAt.toJSDate (), deletedAt: userEntity.deletedAt?.toJSDate () } } } 通信インターフェース実装 ここまででユーザ登録処理の UseCase の実装が完了しました。次にこれを呼び出すインターフェースの実装をしていきます。 ここでは gRPC で実装する場合を例に実装していきます。 protocol buffer 定義 syntax = &#34;proto3&#34;; package sample.user.command_user; service CommandUser { rpc CreateUser(CreateUserRequest) returns (CreateUserResponse); } message CreateUserRequest { string name = 1; } message CreateUserResponse{ bool success = 1; } Controller 実装 // src/subdomains/user/UesCase/CreateUser/CreateUserController.ts export class CreateUserController { constructor( private createUserUseCase: CreateUserUseCase ) async createUser ( req: CreateUserRequest , metadata?: Metadata ) : Promise &lt; CreateUserResponse &gt; { const reqDTO = req as CreateUserRequestDTO const res = await this .createUserUseCase.execute ( reqDTO ) if ( res.isFailed ()) { const error = res.value switch ( error. constructor) { case CreateUserAlreadyExistsError: case CreateUserFailedToCreateUserError: case UnexpectedError: case BadRequestError: default : return { success: false } } } return { success: true } } } これでユーザ登録処理の実装が完了しました。 gRPC で対象のインターフェースを呼び出すことで、DB に対象ユーザのデータが登録ができていることを確認することができます。 ユーザ作成直後に処理を実行したい場合 ユーザ登録処理が完了した直後、メールなどの通知を送信したりレポートデータを書き込みたいなどの後続処理を実装する必要が出てくるケースがあります。その場合は、既存の UseCase (今回でいうと CreateUserUseCase )に手を加えて実装はせず、別の UseCase として各処理を実装し、 DomainEvent を利用して処理を実行するのが望ましいです。 ① CreateUserUseCase を実行 ② User エンティティを生成したときに UserCreated イベントを発行。データの永続化が完了した時点でイベントを発火させる。 ③ Subscription がイベントの発火を検知して、後続処理を実行する。 DomainEvent User エンティティが生成される際に、対象の Entity 情報を EntityID でタグ付けした DomainEvent を発行します。 この時点では、 DomainEvent を発行しているだけで、まだ発火はしていません。 // src/subdomains/user/Entities/User.ts - export class User extends Entity&lt;UserProps&gt; { + export class User extends AggregateRoot&lt;UserProps&gt; { // ... public static create(props: UserProps, id?: UniqueEntityID): Result&lt;User&gt; { const user = new User({...props}, id) + // 新規作成時のみイベントを発行するため、 id が付与されていない場合に実行 + if (!id) { + this.addDomainEvent(new UserCreated(user)) + } return Result.ok&lt;User&gt;(user) } } イベントの発火は、データの永続化の完了を検知したタイミングで実行します。 TypeORM の場合を例にとると、 Entity Listener を使って以下のように実装します。 // src/subdomains/user/Infrastructures/typeorm/User.ts @Entity() export class User { // ... + @AfterInsert() + dispatchAggregateEvents() { + const aggregateId = new UniqueEntityID(this.id); + DomainEvents.dispatchEventsForAggregate(aggregateId); + } } Subscription は、 DomainEvent の発火を検知して、DomainEvent 内の Entity 情報を使って後続処理を実行するように実装しておきます。その上で、 Subscription をサーバ起動時に常時監視状態にしておくことで、ユーザ登録完了後に後続処理を実行する実装の完了です。 おわりに マイクロサービスのバックエンドサービスの実装に焦点を絞って、オニオンアーキテクチャをベースに設計をしました。 実際にこの構成を採用してシステムを実装した所感として、従来の MVC アーキテクチャなどの実装と比較して良かった点、気になる点をまとめると以下のとおりです。 良かった点 サービスのコアロジックを独立して実装できるため、ミドルウェアの変更など環境の変化に柔軟に対応可能。 各実装レイヤーごとに明確に責務を割り振ることで、処理の流れを把握しやすい。 一貫したルールでの実装を半ば強制することで、システムの規模が大きくなるにつれて処理が複雑になりすぎるということが起きづらい。 機能の追加・削除が容易で、スケールしやすい。 気になる点 簡単な機能であってもコードの記述量が多くなってしまうため、初期コストが高い。 冒頭で述べたように、運用面でのメリットは多くありますが、初期の実装にかかるコストは高いように思いました。 リリースまでのリソースに余裕があり、長期的に運用をしていく可能性のあるシステムには導入する価値があるように思いますが、スタートアップのような限られたリソースでとにかく早くリリースをしたいという場合は、慎重に採用の判断をしたほうが良い構成であると思います。 各項目の解説については詳細を割愛している部分も多々ありますが、全体像の大まかなイメージだけでも伝われば幸いです。 参考記事 [1] オニオンアーキテクチャとは何か - https://qiita.com/cocoa-maemae/items/e3f2eabbe0877c2af8d0 [2] DDDで設計するならCQRSの利用を検討すべき - https://qiita.com/ledmonster/items/22b00c65208dffeff7e4 [3] よくわかるSOLID原則5: D(依存性逆転の原則) - https://note.com/erukiti/n/n913e571e8207 [4] khalilstemmler.com - https://khalilstemmler.com/