【Next.js】Server Side RenderingでABテスト(Google Optimize)を実装した話

はじめに

初めまして、レバレジーズ株式会社の小林といいます。
私は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について

プリレンダリング(ページにアクセスがあったときにサーバー側で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

Reactにおける責務(UI/ロジック)の切り分け

はじめに

初めまして、レバレジーズ株式会社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 (
    <>
      <h1>{data.title}</h1>

      <!-- UI表示の分岐ロジック -->
      <p>{state ? 'on' : 'off' }</p>
    </>
  )
}
一言でロジックと言っても、以下の2種類に分類できると考えています。
  • UIロジック
  • 業務ロジック

UIロジック

UIロジックとは、UIの制御のみを目的としたロジックです。

具体例としては以下の様な挙動が挙げられます。
  • トグルボタンのon/offの制御
  • プルダウンの開閉の制御
const Toggle = ({ isActive }) => {
  return isActive ? <ActiveToggle/> : <InActiveToggle/>
}
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 (
    <>
      <h1>価格: {price}円</h1>
      <p>内訳</p>
      <ul>
          <li>個数: {quantity}個</li>
          <li>単価: {unitPrice}円</li>
          <li>税率: {taxRate}%</li>
          <li>送料: {shippingCost}円</li>
      </ul>
    </>
  )
}

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 (
        <div style={{
          width: 15px;
          height: 15px;
          backgroundColor: white;
          border: 1px solid black;
          borderRadius: 10px;
          display: flex;
          alignItems: center;
          justifyContent: center;
        }}>
          <span style={{
            width: 5px;
            height: 5px;
            backgroundColor: blue;
            borderRadius: 10px;
          }} />
        </div>
      )
    }
    const Title = ({ text }) => {
      return <h1 style={{ fontSize: 20px }}>{text}</h1>
    }

    atomsの特徴

  • コンポーネントの最小単位。
  • ロジックは持たない。
  • ステートレスなコンポーネント。
  • システム全体で流用される。
  • atoms実装の注意点

    atomsでは基本的にreturnの上にロジックが書かれることはありません。

    というのも意識的にロジックを書かないというよりかは、「最小単位」のコンポーネントなので責務も小さく自然と書く必要がなくなるイメージです。

    ロジックを書く必要がある場合はそのatomsは「最小単位」として扱うべきでない可能性があります。
    更に最小のコンポーネントになり得ないかを疑う余地があるかと思います。

    molecules

    import { ActiveRadio, InActiveRadio } from '~/atoms/radio';
    
    // on/offができるラジオボタン
    const Radio = ({ isActive }) => {
      return isActive ? <ActiveRadio /> : <InActiveRadio />
    }
    import { Button } from '~/atoms/button';
    
    // ファイルアップロードフォーム & ボタン
    const FileUpload = ({ name, text }) => {
      const ref = useRef(null);
      const onClickInput = () => ref.current.click();
    
      return (
        <>
          <Button onClick={onClickInput} >{text}</Button>
          <input hidden name={name} ref={ref} type="file" />
        </>
      );
    }

    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 <Pulldown {...plldownProps}/>
    }
    検索可能なプルダウン
    // SearchPulldown.tsx
    const SearchPulldown = () => {
      const pulldownProps = usePulldown()
      // ... SearchPulldown独自のロジック
      return (
        <>
          <!-- other component -->
          <Pulldown {...plldownProps}/>
        </>
      )
    }
    複数選択可能なプルダウン
    // MultiPulldown.tsx
    const MultiPulldown = () => {
      const pulldownProps = usePulldown()
      // ... MultiPulldown独自のロジック
      return (
        <>
          <!-- other component -->
          <Pulldown {...plldownProps}/>
        </>
      )
    }
    ここでusePulldownの処理の中でSearchPulldown.tsxのみに例外的な処理の追加が必要になったとします。

    根本的な対処としてはusePulldownの中から例外的な処理が入る部分を切り出して他のPulldownMultiPulldownにも変更箇所のコードを移植することになるかと思います。

    しかし元々一元管理されていたコードをわざわざ重複させる書き方は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 (
        <>
          <Button onClick={onClickInput} >{text}</Button>
          <input hidden name={name} ref={ref} type="file" />
        </>
      );
    }
    逆に特定の業務ドメインに縛られているとは以下のような状態です。

    違いはボタンの名前が固定値になっただけです。
    import { Button } from '~/atoms/button';
    
    // ファイルアップロードフォーム & ボタン
    const FileUpload = ({ name }) => {
      const ref = useRef(null);
      const onClickInput = () => ref.current.click();
    
      return (
        <>
          <Button onClick={onClickInput} >プロフィール画像のアップロード</Button>
          <input hidden name={name} ref={ref} type="file" />
        </>
      );
    }
    どんなシステムを開発しているかの前提もないですが、 上記のファイルアップロードボタンはユーザ情報の登録や編集を担う画面でしか活用できないコンポーネントになることが想像できます。

    もしシステムが商品登録等の機能を持っていたらそちらでもファイルアップロードボタンは必要になりそうです。

    しかし上記のように「特定の業務ドメイン」に限定されたコンポーネントだと再利用はできません。

    再利用性の低いコンポーネントがmoleculesレベルで存在しているとシステムの規模が大きくなった時に以下のどちらかの苦しい対処が発生します。

  • 利用箇所が限定的なコンポーネントが極端に増える
  • 無理くりatomsをpages内で組み合わせて実装

  • なのでmoleculesでは特定の業務ドメインに縛られないコンポーネントであるべきです。

    特定の業務ドメインとは何か

    特定の業務ドメインとは何かについてさらに詳しく触れます。

    単純に「業務ドメイン」とは言わずに「特定の業務ドメイン」と呼んでいるのには意味があります。

    極端な例ですが、開発するシステムが商品管理システムでそれ以外の情報を扱わなかったとします。
    • 商品管理システム
      • 商品登録機能
      • 商品編集機能
      • 商品削除機能
    この場合には、特定の業務ドメインは「商品登録機能、商品編集機能、商品削除機能」が該当します。

    なので以下のようなコンポーネントが実装されてもシステム全体で活用できるのでmoleculesとして成立します。
    import { Button } from '~/atoms/button';
    
    // ファイルアップロードフォーム & ボタン
    const FileUpload = ({ name }) => {
      const ref = useRef(null);
      const onClickInput = () => ref.current.click();
    
      return (
        <>
          <Button onClick={onClickInput} >商品画像のアップロード</Button>
          <input hidden name={name} ref={ref} type="file" />
        </>
      );
    }
    ただシステムが以下のような構成だと上記のコンポーネントは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 (
        <>
          <Title>価格: {price}円</Title>
          <Paragraph>内訳</Paragraph>
          <PriceList
            quantity={quantity}
            taxRate={taxRate}
            shippingCost={shippingCost}
          />
        </>
      )
    }

    organismsの特徴

  • atoms、moleculesの複合コンポーネント。
  • 業務のドメイン情報をDOMに持つことがある。
  • APIとの疎通や業務ロジックを持つことがある。
  • organismsの実装の注意点

    業務ロジックとUI(DOM)を疎結合にすることです。

    organismsは上記の例のように業務ロジックや業務のドメイン情報をDOMに持ちます。

    ただ上記のコード例は悪い例です。

    以下のように業務ロジックとUIが密結合していると悪い点が3点あります。
    1. 再利用性の低下
    2. データ構造が見えない
    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 ( <> - <Title>価格: {price}円</Title> + <Title>価格: {isMember ? price * 0.9 : price} <p>内訳</p> <PriceList quantity={quantity} taxRate={taxRate} shippingCost={shippingCost} /> </> ) }
    この例ではコードの行数が短いですが、実際の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 ( <> - <Title>価格: {isMember ? price * 0.9 : price} + <Title>価格: {isGoldMember + ? price * 0.8 + : isMember + ? price * 0.9 + : price + }円 <p>内訳</p> <PriceList quantity={quantity} taxRate={taxRate} shippingCost={shippingCost} /> </> ) }
    元々のコードで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 (
        <>
          <Title>価格: {price}円</Title>
          <p>内訳</p>
          <PriceList
            quantity={quantity}
            taxRate={taxRate}
            shippingCost={shippingCost}
          />
        </>
      )
    }

    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 (
        <Price
            price={price}
            quantity={quantity}
            taxRate={taxRate}
            shippingCost={shippingCost} />
      )
    }

    ContainerComponentsの特徴

    業務ロジックの実装に着目したコンポーネントになります。

    どう表示されるかの責任はすべてPresentationalComponentsに委ねます。
    ContainerComponentsでは位置の調整や余白のためにスタイルを当てることもありません。

    Presentational/Container Componentsの実装方針

    organismsの中でもPresentationalComponentsContainerComponentsはディレクトリも区分けして明確に違うものとして定義しておくのが良いです。

    そうすることで修正が必要な場合にどちらのコンポーネントを修正すべきかも明確に判断が付きます。
    ただコンポーネントの実装を始める前から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人でも増えれば嬉しく思います!(詳しくはこちら

    エンジニアが事業戦略の実現のために認証基盤システムを構築した話

    はじめに

     こんにちは、レバレジーズ株式会社エンジニアの住村です。 現在、私が開発に関わっているレバテックでは、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 "s#refs/tags/##g")
            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人で協力して一つのものを作るという内容でした。

    ※ 今回、企業数と候補者数は同じ( n=1000)であるとします
    ※ この安定マッチングとは、企業と候補者、共に現在組んでいるペアよりも希望順位が高いペア(以降では、ブロッキングペアと呼びます)が存在しない状態を指します

    テックバトルの問題に挑戦!

    上記でも少し触れた通り、この問題の肝はCさんであり、Cさんが如何にこのプログラムを実装するかにチームの命運がかかってます笑

    今回、企業(候補者)数  n に対して、全組み合わせを力任せで調べるBrute-Forceアルゴリズムを用いると計算量は  n ! になります。 n が小さい値の時はまだマシですが、 n=1000 ともなると計算量があまりに膨大になってしまうので、このような時はアルゴリズムの知識が重要になりますね!

    今回の問題で活躍するのは、Gale-Shapleyアルゴリズムというもので、このアルゴリズムを用いると計算量は  O(n^2)で済ませる事が出来ます。恐ろしいほど強力ですね!😲

    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) < 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) < n:
       gale_shapley(corps, candidates, corp_desire_order, candidate_desire_order, corp_to_candidate, candidate_to_corp)
    

    書いてみると意外とシンプルですよね。
    皆さんも是非他の言語でチャレンジしてみてください!

    最後に

    今回、テックバトルを通して、「新しいアルゴリズムを知れたから良かった!」というよりは、エンジニア同士の交流が何よりも貴重で楽しかったです笑

    レバレジーズのエンジニア組織は、技術力だけではなく、今回のイベントのようなエンジニア同士の交流も大事にする組織です。日頃からこのようなイベントを行うことで部署間の壁がなく、気軽に悩みを相談出来る環境が常に整っており、とても働きやすいと日々感じています!

    皆さんもこんなレバレジーズで一緒に働いてみませんか? レバレジーズに少しでも興味を持っていただけた方は、是非下記リンクを覗いてみてください!


    leverages.jp

    新卒1年目のエンジニアがUX改善のPDCAを構築し、1年間でCVRを13%改善した話

    はじめに

    こんにちは。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

    未経験インターンが新規事業に飛び込んで 1年半でリーダーになるまで

    はじめに

    この記事では、未経験から短期間でリーダーになるためのマインドセットをご紹介したいと思います。
    僕のように未経験でエンジニアになる方、リーダー経験を若いうちに積みたい方には、ぜひ読んでいただきたいなと思っております!

    自己紹介

    初めまして、エンジニアの五十嵐です。
    僕は、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

    Atomic Designを導入して開発コストの削減とデザインの統一を実現した話。

    はじめに

    こんにちは。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 "./Example.module.scss"
    
    const Example: React.FC = () => {
      return (
        <>
        <h2 className={styles.title}>タイトル</h2>
        <div className={styles.box}>
          {/* 中にコンテンツ */}
        </div>
        </>
      )
    }
    
    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

    全文検索 MySQL FULLTEXTインデックスからElasticsearchへ切り替えた話

    はじめに

    こんにちは、レバレジーズ フリーランスHub開発チームです。 現在、弊社が運営するフリーランスHub というサービスでは検索エンジンとしてElasticsearchを採用しています。 この記事では、エンジニアがサービスの成長速度と工数を考慮した上で、Elasticsearchを採用するに至った背景や理由、そして、MySQLからElasticsearchへ移行した結果どれくらいパフォーマンスが改善したか、などを紹介していきたいと思います。

    サービス紹介

    フリーランスHub は全国のフリーランスエージェントの保有案件から、10万件以上の案件をまとめて掲載しているエンジニア・クリエイター向けのフリーランス求人・案件メディアです。

    2021年4月にリリースした新規事業で、サイト内でフリーランスエージェントの案件を集約して掲載しており、フリーランスを検討される方が案件に一括で閲覧・応募することができます。

    また、サイト内で開発言語や職種別に案件の検索ができることに加え、エージェントの詳細ページから各エージェントの特徴やサービスを受けるメリットなどを比較し自分にあったフリーランス案件・フリーランスエージェントを探すことが可能です。

    f:id:r-nakanoue-lvgs:20220224161518j:plain

    検索機能の説明

    検索パネルは開発スキルや職種、業界、こだわり条件(週3など)や、都道府県、路線、駅での検索機能があります。

    f:id:r-nakanoue-lvgs:20220224161624p:plain

    f:id:r-nakanoue-lvgs:20220224161640p:plain

    サービス設計フェーズ

    見通しを立てる

    事業をスタートする際は様々な軸で数年の見通しを立てます、今回のテーマに影響するものはこの辺です。

    f:id:r-nakanoue-lvgs:20220224171553p:plain

    フリーランスHubは機能数が少ないので小さくスタートし、短期間で拡張と機能追加をしてサービスを成長させていく方針にしました。 その他見通しの説明は記事の最後で補足しておきます。

    アーキテクトの選定

    f:id:r-nakanoue-lvgs:20220224172926p:plain

    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万件〜)

    f:id:r-nakanoue-lvgs:20220224173001p:plain

    機能追加・拡大 (9〜12万件)

    f:id:r-nakanoue-lvgs:20220224173017p:plain

    処理説明

    • 案件データ作成 : HTMLクロール等で収集した元データから表示用のテーブルを作成
    • 全文検索データ作成 : タイトル・内容・スキルなどの文字列を検索用テーブルの1つカラムへ
    • 件数作成 : 表示する事の多い全案件数などはカウントテーブルへ事前に入れておく

    サイトからの検索処理

    • 全ての検索処理は検索テーブル、表示は表示テーブルと役割を分ける
    • ヒットした検索テーブルからidを取り、表示テーブルをidで検索し表示する
    • 例えば検索パネルで PHPで検索した場合
      • PHPという文字列で検索テーブルを検索する
      • スキルテーブルからPHPを探しテーブルidで検索というプロセスは行わない
    • 一覧だけでなくサジェスト処理なども同じ

    検索データを上手に作成しておく事が必要となる代わりに、検索SQLでテーブルをジョインする必要が無くシンプルになります。 Elasticsearch利用時も検索テーブルだけのシンプルなクエリに出来ます。

    インフラ構築

    f:id:r-nakanoue-lvgs:20220224173107p:plain

    Elasticsearchは最終的なスペックでビルドし利用するまでスペックを落として運用していました。 AWSはCDKで構築したのでサイズとノード数を変更し実行するだけで切り替わる構成です。

    Elasticsearchに限らず初期は不要なケースも最初から作成しておきました。例えば、MQはRDSでスタートし後でSQSに切り替えました。 インフラ変更は影響範囲が広く、後で追加を行う時の苦労が多いため、構成は最初から作っておき利用時に起動する等により、サービスの安定性と成長スピードを損なわないメリットがあります。 無駄なコストが発生してしまうので、サービス成長速度を上げ無駄となる期間を短くします。


    比較

    実行結果

    早速結果から、クエリキャッシュOFFで実行しています。 MySQL long_query_time は 0.25s を閾値にして運用しています。

    f:id:r-nakanoue-lvgs:20220224173144p:plain

    案件数が5万件ほどの時期にソート順の要件が増えてきました。

    • 優先 : 一部の案件を上位に表示
    • 募集終了 : 募集終了した案件も一覧の後ろの方に追加

    指定した検索条件で関連順の場合はMySQL FULLTEXTインデックスでも高速ですが、独自にソートを追加したSQLは速度低下が見られました。 ダミーデータで作成した32万件のテーブルにクエリ実行した結果、ヒットした件数が増えるほどソート処理に大きく時間がかかる事がわかりました。 契約数・掲載数も順調に伸びているため、早めにElasticsearchに切り替えることにしました。

    1週間ほどエンジニア1名をElasticsearchに貼り付け32万件のダミーデータでクエリを発行し大きく改善する事を確認、他の追加開発を止める事もなく、その後3週間ほどで切り替えが完了。 トークナイザーやデプロイの最適化など、経験と専門性が必要な領域は切り替え後に作り込みました。

    クエリ

    テーブル、インデックスは item_search です。

    f:id:r-nakanoue-lvgs:20220224173452p:plain

    CREATE文は補足に記載しておきます。

    Aurora(MySQL)

    初期

    SELECT
        `item_id`, `status`, `created_at`, `base_value`
    FROM
        `item_searches`
    WHERE
        MATCH(search_string) AGAINST(
        -- キーワードに「東京」が含まれる案件の中で、「Java」もしくは「PHP」の案件の取得
            ' +( ""東京"") +( ""Java PHP"")' 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(
                ' +( "東京") +( "Java PHP")' IN BOOLEAN MODE
            )
    )
    UNION ALL
    (
        SELECT
            `item_id`, `status`, `created_at`, `base_value`
        FROM
            `item_searches`
        WHERE
            `status` = 2 AND
            MATCH(search_string) AGAINST(
                ' +( "東京") +( "Java PHP")' IN BOOLEAN MODE
            )
    );
    

    上位表示する優先データは別のクエリで先に求めています。 上2つのSQLをUNIONしているのは上位表示したデータの重複を防ぐためです。ヒット件数が多い検索条件であるほどソート処理に時間がかかります。なおUNION ALLにしてもヒット件数が多い限りほぼ改善しません。 また、WHERE句でMATCH()を使用すると自動的に関連度順でソートされますが、SELECTステートメントでもMATCH()を使用する事でORDER BY句で関連度順ソートを指定する事ができ、statusと併せてソートする事も可能です。ただ、前者に比べ後者の方がソートのオーバーヘッドがかかるため後者は採用しませんでした。

    Elasticsearch

    +優先+募集終了

    GET /item_search/_search
    {
      "track_total_hits": true,
      -- 表示する案件は1ページあたり30件のためsizeを30で指定
      "size": 30, 
      "query": {
        "bool": {
          "must": [
             {   
              "match": {
                "search_string": {
                  "query": "Java PHP",
                  "operator": "or"
                }   
              } 
            },
            {   
              "match": {
                "search_string": {
                  "query": "東京",
                  "operator": "and"
                }   
              } 
            }
          ],
          "should": [
            {
              "terms": {
                "item_id": [
                  25000
                ],
                "boost": 10000
              }
            },
           {
              "terms": {
                "item_id": [
                  30000
                ],
                "boost": 8000
              }
            },
            -- <中略> --
            {
              "terms": {
                "item_id": [
                  45000
                ],
                "boost": 2000
              }
            }
          ]
        }
      },
        "sort" : [
        { 
          "status":{
            "order" : "asc"
          }
        },
        {
        "_score":{
            "order" : "desc"
          }
        }
      ]
    }
    

    上位表示する優先データはスコアをブーストする方法で実現しています。


    切替時のプログラム変更

    データ作成

    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にも登録

    検索用データの工夫

    f:id:r-nakanoue-lvgs:20220310183456p:plain

    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を作ります。

    スキル(PHP・Linuxなど)での検索の重要性が高いためngram無しでINDEXを作成しました。

    ALTER TABLE `item_searches` ADD FULLTEXT KEY `idx` (`search_string`);
    

    例えば「...業務内容はブリッジSEとして...」を解析しても「ブリッジSE」ではヒットしない挙動となり、 Elasticsearch移行前の大きな課題でした。

    Elasticsearch

    トークナイザー

    modeをnomalに設定することで、辞書登録した文字列を1単語として単語認識させることができます。

    "tokenizer" : {
      "kuromoji" : {
        "mode" : "normal",
        "type" : "kuromoji_tokenizer"
      }
    }

    モードがデフォルトのsearchだと、例えば javascriptを形態素解析した場合、javascriptの他に java script としても分解されるため、検索結果にjavaの案件が含まれる事になります。 今回の事例では辞書登録した単語を1単語として単語認識させることで検索結果の精度を上げています。

    Character Filter ※2022/03/10 追記

    char_filterで検索文字列の大文字を小文字に統一する設定も行なっています。

    "char_filter": {
     "lc_normalizer": {
      "type": "mapping",
      "mappings": [
           "A => a",
           "B => b",
           "C => c",
        --- <中略> --- 
           "Z => z"
        ]
      }
    },

    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
    {
      "aliases" : {
        "item_search_replace" : { }
      },
      "settings" : {
      "index" : {
        "max_result_window" : "500000",
          "analysis":{
            "char_filter": { -- 2022/03/10追記
              "lc_normalizer": {
                "type": "mapping",
                "mappings": [
                     "A => a",
                     "B => b",
                     "C => c",
                 --- <中略> --- 
                     "Z => z"
                  ]
              }
            },
            "filter" : {
            "greek_lowercase_filter" : {
              "type" : "lowercase",
                "language" : "greek"
              }
            },
          "tokenizer" : {
            "kuromoji" : {
                "mode" : "normal",
                "type" : "kuromoji_tokenizer",
                "user_dictionary_rules" : [ 
                    "Java,Java,Java,スキル", 
                    "PHP,PHP,PHP,スキル", 
                    -- <中略> --
                    "渋谷駅,渋谷駅,渋谷駅,駅名",
                    "東京駅,東京駅,東京駅,駅名"
                ]
              }
            },
          "analyzer" : {
              "analyzer" : {
                "type" : "custom",
                "char_filter" : "lc_normalizer", -- 2022/03/10 追記
                "tokenizer" : "kuromoji",
                "filter" : ["kuromoji_baseform", "greek_lowercase_filter", "cjk_width"]
              }
            }
          }
        }
       },
      "mappings" : {
        "properties" : {
          "id" : {
            "type" : "integer"
          },
          "item_id" : {
            "type" : "integer"
          },
          "search_string" : {
            "type" : "text",
            "analyzer": "analyzer",
            "fields" : {
              "keyword" : {
                "type" : "keyword",
                "ignore_above" : 256
              }
            }
          },
          "base_value" : {
            "type" : "integer"
          },
          "status" : {
            "type" : "integer"
          },
          "created_at" : {
            "type" : "date"
          },
          "updated_at" : {
            "type" : "date"
          }
         }
      }
    }

    レバレジーズのHRTech事業について

    はじめに

    こんにちは。レバレジーズ株式会社エンジニアの加藤です。 今回は、私が所属しているHRテック事業部における新規SaaSの開発についてご紹介したいと思います。

    事業領域について

    私たちの事業部が扱う領域はHRTechといい、主に会社の人事業務に対するサービスを展開しています。「人事」と聞くと採用活動をイメージされることも多いですが、ここでは「従業員が入社し、退職するまでに関わる業務の全般」を指します。そのため、採用管理や勤怠管理、タレントマネジメント、給与管理などをはじめとした、さまざまなSaaSが存在しています。勤怠管理サービスなどは使ったことのある方も多いのでイメージしやすいのではないでしょうか。

    HRTechの役割

    人事業務は人材採用、人材育成、離職対策、人事評価、労務管理など多岐に渡り、従業員が入社から退職するまでなくてはならない業務です。適切に人事業務が提供されない会社では従業員は力を発揮できませんし、当然ながら定着もしません。つまり、人事業務が最適化されているかどうかは「会社の発展や成長」を大きく左右することになります。また、会社規模が大きくなればなるほど、人事業務の複雑性は指数関数的に増していきますので、ますます仕組みとして整っていなければなりません。そのため、手続きや情報の管理を効率的に行い、組織設計、採用、人材育成、離職対策など有効な施策立案に繋げ、人事の価値創出に寄与することがHRTechサービスに求められる役割です。

    HR(人事)領域の抱える課題

    しかしながら、既存のサービスは機能によって(採用管理・勤怠管理・労務管理といったように)分類化されており、人事業務を包括的にカバーできるものはありません。 例えば、「採用・人材育成・離職対策」といった機能は相互に関係性を持ちますが、それぞれの機能ごとにプラットフォームが分断されていれば、統合された情報を元にした適切な人事オペレーションを提供することは難しくなります。 既存の人事業務を各機能ごとにデジタル化(またはDX化)することはできても、HRTechを利用することで人事の価値創出に繋げることができていないということが、人事業務とHRTechという領域の課題といえます。

    私たちのプロダクトの意義

    前述の課題に対して、私たちのサービスでは例えば、各人事業務で蓄積された従業員の志向性やエンゲージメント、実績や評価などの情報を元に個別適切な配置転換や離職対策に活用することができます。 昨今の潮流としても、企業は労働者の価値観やワークスタイルニーズの多様化などにより、以前よりもパーソナライズされた労働環境の提供が求められています。 より個に適した形で人事業務を行うには、分断されたプラットフォームではなく、包括的に情報を管理できる環境が必要になります。

    チーム体制について

    私たちの事業部ではマイクロサービスアーキテクチャを採用しており、開発体制としてはスクラムを採用しています。またスクラムの中でもLeSSと呼ばれるものに近く、4-6人程度を1群としたチームが4チームほどで動いています。それぞれのチームが1つのプロダクトを開発するよう自己組織化されています。

    チーム体制のメリット

    1. 意思決定の速さ 事業部としての大きな方針はありますが、チームのための意思決定はチーム独自で行っています。人員構成もエンジニアが主体となっているためエンジニア主導でコトが進みその速度も速いです。少人数ゆえメンバー1人1人の意見がダイレクトにチームに反映されます。

    2. 業務の範囲に制限がない 担当業務は単純な作業に留まることはなく、フロントエンドやバックエンド、インフラを問わず機能の要否や設計から実装まで行います。 作業範囲が広いため、様々な技術に触れながら成長することができますし、事業計画に直接携わるなど、エンジニアに留まらない仕事の仕方ができる可能性もあります。 もちろん作業範囲が広いだけではありません。例えばプロダクトの仕様についても、単に仕様を満たすための部分的な作業ではなく、プロダクト全体、ひいてはサービスの成長を見据えた上で設計を行うため、事業経営の中核に立って開発を進めることができます。

    3. コミュニケーションの取りやすさ 少人数のチーム構成のため、メンバーと自分の作業関係が把握しやすく、開発に関するやりとりも齟齬なく行うことができます。 開発業務はハードですが、他のチームも含めて非常に安心感のある組織だと自信を持って言えます。

    使用技術について

    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も導入を進めており、より緻密で効率的なサーバー管理を目指しています。

    便利さによる標準化の浸透

     技術選定したはいいものの浸透しないということもあると思います。大体は新しい技術を覚えたり切り替えることに対する不安が原因なことが多いです。今回選定した技術に関してほぼ全て社内ライブラリを用意し弊社の開発に特化させることで、

    新しいことを覚えるコスト <<< 選定された技術を使うメリット

    という状況を作り出しました。一つの技術選定だけでは実現は難しいですが、複数の技術をうまく組み合わせその状況を作り出せれば、あとは自己増殖的に浸透してくれます。

    おわりに

     レバレジーズは「関係者全員の幸福を追求する」ミッションを達成するため、今回ご紹介したように、開発効率を高め、事業実現を素早く行えるエンジニア組織への変革をすすめています。そのため技術選定も技術的好奇心や目下の開発だけにとらわれない選定を心がけて行っています。しかし、まだまだ改善できるところは多々あります。もし「技術選定を行って組織を変えていきたい」と思っている方がいましたら、ぜひ一緒に働きませんか?エキサイティングな仕事ができると思います。 ご興味ある方は、こちらからエントリーをお願いします! https://recruit.jobcan.jp/leverages/recruit.jobcan.jp

    レバレジーズの急拡大を支える内製化SFAとは

    はじめに

    こんにちは! エンジニアの室井です。

    近年、レバレジーズは事業の急拡大を続けていますが、その中核となっている「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

    Apollo Client導入により状態管理の複雑性を削減し開発効率を向上させた話

    はじめに

    こんにちは、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<Store, Action> = (store, action) => {
        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 = () => {
        const { data, loading, error } = useIndexQuery()
        const initialStore: Store = {
            isInit: true,
        }
    
        const [store, dispatch] = useReducer(reducer, initialStore)
    
        useEffect(() => {
            if (!data || !store.isInit) {
                return
            }
    
            dispatch({
                type: 'SET_INIT',
                payload: data,
            })
        }, [data])
    
        if (store.isInit && loading) {
            // 必要に応じてカスタマイズ
            return <div>Loading...</div>
        }
    
        if (error) {
            // 必要に応じてカスタマイズ
            return <div>Error...</div>
        }
    
        return <Presentation data={data} store={store} dispatch={dispatch} />
    }
    

    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<Action>
    }
    
    export const Presentation: React.FC<Props> = ({ data, store, dispatch }) => {
        return (
            <>
                <JobContainer
                    clients={data.clients}
                    jobs={data.jobs}
                    selectedClient={store.selectedClient ?? '1'}
                    selectedJob={store.selectedJob ?? '1'}
                    dispatch={dispatch}
                />
            </>
        )
    }
    

    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<ClientTestFragment>
        jobs: Array<JobTestFragment>
        selectedClient: string
        selectedJob: string
        dispatch: React.Dispatch<any>
    }
    
    export const JobContainer: React.FC<Props> = ({ clients, jobs, selectedClient, selectedJob, dispatch }) => {
        const handleChangeClient: React.ChangeEventHandler<HTMLSelectElement> = e => {
            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) => String(job.clientId) === nextClientId),
                },
            })
        }
    
        const handleChangeJob: React.ChangeEventHandler<HTMLSelectElement> = e => {
            dispatch({
                type: 'SET_VALUE',
                payload: {
                    name: 'selectedJob',
                    values: e.target.value ? String(e.target.value) : String(jobs[0].id),
                },
            })
        }
    
        return (
            <JobPresentation
                clients={clients}
                jobs={jobs}
                selectedClient={selectedClient}
                selectedJob={selectedJob}
                handleChangeClient={handleChangeClient}
                handleChangeJob={handleChangeJob}
            />
        )
    }
    

    organisms/Job/JobClient/Presentation.tsx

    import React from 'react'
    import { ClientTestFragment, JobTestFragment } from 'generated/graphql'
    
    interface Props {
        clients: Array<ClientTestFragment>
        jobs: Array<JobTestFragment>
        selectedClient: string
        selectedJob: string
        handleChangeClient: React.ChangeEventHandler<HTMLSelectElement>
        handleChangeJob: React.ChangeEventHandler<HTMLSelectElement>
    }
    
    export const JobPresentation: React.FC<Props> = ({ clients, jobs, selectedClient, selectedJob, handleChangeClient, handleChangeJob }) => {
        return (
            <>
                <div>
                    <label>
                        企業:
                        <select value={selectedClient} onChange={handleChangeClient}>
                            {clients.map((client: ClientTestFragment) => {
                                return (
                                    <option value={client.id} key={client.id}>
                                        {client.name}
                                    </option>
                                )
                            })}
                        </select>
                    </label>
                    <label>
                        求人:
                        <select value={selectedJob} onChange={handleChangeJob}>
                            {jobs
                                .filter((job: JobTestFragment) => String(job.clientId) === selectedClient)
                                .map((job: JobTestFragment) => {
                                    return (
                                        <option value={job.id} key={job.id}>
                                            {job.name}
                                        </option>
                                    )
                                })}
                        </select>
                    </label>
                </div>
            </>
        )
    }
    

    導入して分かったこと

    実装したメンバーの声も含め、よかったこと、困ったことを紹介します。

    • よかったこと
      • 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年目ですが、この技術選定を担当させていただきました。レバレジーズでは、経験が浅くても提案が受け入れてもらえる環境があります。このような環境に興味がある方はぜひ一緒に働きましょう。

    新卒1年目が開発プロセス改善のためにした3つのこと

    はじめに

    みなさん、こんにちは! メディカルケア事業部の『看護のお仕事』にてシステム担当をしている渡辺と申します。私は2020年に新卒で入社し、同年7月からメディカルケア事業部のエンジニアとして配属されました。職種や経験年数に関わらず自発的な提案を受け入れて任せるレバレジーズの企業文化の下、私も配属後から開発プロセスの改善案を出すうちに、去年の終わり頃から媒体の責任者となり、『看護のお仕事』のサイト品質改善のためにさまざまな施策の実装を行っています。

    今回は、『看護のお仕事』で開発プロセス改善を行ったことについてお話したいと思います。媒体責任者として、開発に関わるメンバーがスムーズに作業にあたることができるよう開発プロセスの改善を進めてきました。

    この記事が開発プロセスの改善策を模索している方やレバレジーズに興味のある方の一助となれば幸いです。

    『看護のお仕事』について

    『看護のお仕事』は看護師向けの転職支援サービスです。『看護のお仕事』は無料で使うことができ、自分にあった職場を納得の行くまで探すことをサポートします。本記事をご覧いただいている方で、看護師のお知り合いがいましたら、ぜひ『看護のお仕事』をご紹介ください!

    各種ミーティングについて

    『看護のお仕事』の開発チームは、スクラム開発を参考にオリジナルのスクラム開発体制でサイト運営を行っています。また、スプリントは2週間単位で回しています。今回お話しする中で、関係のあるミーティングは下記になります。

    • 施策レビュー会
      • 参加者:オウンド責任者、ディレクター、デザイナー兼コーダー、エンジニア
      • 内容:施策について意見を出し、話し合う会です。『看護のお仕事』のサイト品質改善を進めるにあたって施策が有効かなどを話し合うための場になります。
    • 工数見積もりのミーティング
      • 参加者:エンジニア
      • 内容:実装に必要な工数を見積もります。この時に実装方針についても話し合います。見積もりにはプランニングポーカーを採用しています。
    • 振り返り会
      • 参加者:オウンド責任者、ディレクター、デザイナー兼コーダー、エンジニア
      • 内容:KPTを用いてスプリントの振り返りを行います。

    実際に何したの?

    では、実際に行った改善策について3つお話しします。

    ①やるべきタスクの精査

    『看護のお仕事』開発チームでは、実装が必要なタスクに工数付けを行います。エンジニア個々人のリソースと工数管理の観点から、見積もりは絶対見積もりで行っています。

    私がチームへ配属されてから少し経過した頃、ABテストを行う施策に実際の作業時間が5分であるにも関わらず、1時間の見積もりを割り当てていることに気づきました。ABテストの施策は実装方法の改善がされた結果、5分で作業が出来るほどマニュアル化されており、エンジニア以外でも作業が可能なものだとわかりました。そのため、そもそもエンジニアが対応する必要があるかを見直しました。

    そこで、作業を同施策の他の対応と合わせてコーダーに巻き取ってもらうことを相談し、そのようになりました。結果として、エンジニアで対応が必要かどうかの見直しができ、1時間の工数がつく施策がスプリント内で3~5施策ほどあることが続いていましたので、最大5時間の工数見積もりの削減になりました。また、施策ごとにエンジニアで対応すべきかを確認することの重要性を再確認でき、やるべきタスクの精査をよりしっかりと行うようになりました。

    ②工数付けプロセス改善

    工数をつけるために仕様を把握する必要があります。しかし、『看護のお仕事』開発チームでは工数をつけるときもまだ仕様が詰まりきっていないタスクが多々発生しており、そのことを関係者間で問題であるという共通認識を持てていませんでした。同時に、特定のメンバーが工数付けのミーティングまでにディレクターと短い時間で仕様についてヒアリングを行っていたため、該当メンバーに負担がありました。

    この状況を改善するにあたって、ディレクターへ以下のお願いをしました。 ディレクターもしくは依頼者が、工数付けのミーティングまでに施策の実装をする上で懸念事項がないか事前に確認すること 施策レビュー会中に話がまとまらない場合には別途ミーティングの場を設けること また、エンジニアからも考慮して欲しい観点について施策レビュー会中に発言をするようにしています。

    振り返り会にて相談をしたことで、仕様に抜け漏れが発生していることを共通認識として持つことができました。その結果、既存のミーティングをうまく活用することができ、仕様確認に関するやりとりなどの手間やメンバーへの負担を軽減することができました。

    ③工数ズレをなくすために

    ソースコードレビューでは、レビューを受けてから大幅に実装方針を変更し手戻りすることや、レビュアーと実装者で追加の実装すり合わせが必要になることが多々ありました。これでは予定工数を超過してしまいますし、施策のリリースにも影響します。

    その状況の根本的な原因は、レビュアーと実装者との間で実装方針のすり合わせが不十分なことでした。そこで工数付けの時間に実装方針を話し合い、その内容を実装タスクを管理しているものにコメントとして残すようにしました。このようにして、話し合った内容をいつでも確認できるようにすることでエンジニアチーム全体で共通認識をとりました。

    後々の工数を減らすと同時に、実際の実装対応に近い工数を見積もることができ、工数付けの精度向上にもなりました。

    終わりに

    これらの開発プロセス改善は私が入社後、配属半年で行ったことになります。自身で考えた改善案とその対応策を上長に提案し、チームメンバーと共に相談しながら進めてきました。このようにできたのも、社会人歴や入社年数に囚われずに話を聞いてくれてそれを受け入れてもらえる環境のおかげです。

    組織は変化していくものであると同時に、開発チームという組織も流動的なものです。エンジニアチームも続々とメンバーが加わり、『看護のお仕事』がサービスとして何をすべきかのフェーズも日々進化していきます。柔軟にやり方を模索しながらエンジニア以外の方々とも協力しながら日々サイト運営を行っております。

    本記事が開発プロセスの改善を進めたい方や、弊社に興味のある方に意義あるものであると大変嬉しく思います。最後までご覧いただき、ありがとうございました!

    tailwindcssを導入して感じたメリット・デメリットについて

    はじめに

    こんにちは、レバレジーズ株式会社の牛嶋です。 レバレジーズ株式会社で、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 = () => {
     return (
       <div className="max-w-sm mx-auto flex p-6 bg-white rounded-lg shadow-xl">
       <div className="flex-shrink-0">
         <img className="h-12 w-12" src="/img/logo.svg" alt="ChitChat Logo">
       </div>
       <div className="ml-6 pt-1">
         <h4 className="text-xl text-gray-900 leading-tight">ChitChat</h4>
         <p className="text-base text-gray-600 leading-normal">You have a new message!</p>
       </div>
       </div>
     )
    }
     
    export default ChitChat;
    

    CSS Modulesの場合

    // 公式サイト(https://tailwindcss.jp/docs/utility-first)から引用
    import styles from “./chitChat.modules.scss”
     
    const ChitChat = () => {
     return (
       <div className={styles.chatNotification}>
         <div className={styles.chatNotificationLogoWrapper}>
           <img className={styles.chatNotificationLogo} src="/img/logo.svg" alt="ChitChat Logo">
         </div>
         <div className={styles.chatNotificationContent}>
           <h4 className={styles.chatNotificationTitle}>ChitChat</h4>
           <p className={styles.chatNotificationMessage}>You have a new message!</p>
         </div>
       </div>
     )
    }
     
    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)から引用
    <div className="md:flex">
     <div className="md:flex-shrink-0">
       <img className="rounded-lg md:w-56" src="https://images.unsplash.com/photo-1556740738-b6a63e27c4df?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=448&q=80" width="448" height="299" alt="Woman paying for a purchase">
     </div>
     <div className="mt-4 md:mt-0 md:ml-6">
       <div className="uppercase tracking-wide text-sm text-indigo-600 font-bold">Marketing</div>
       <a href="#" className="block mt-1 text-lg leading-tight font-semibold text-gray-900 hover:underline">Finding customers for your new business</a>
       <p className="mt-2 text-gray-600">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.</p>
     </div>
    </div>
    

    最初こそ慣れるまで時間がかかると思いますが、ある程度の書き方を覚えてしまえば、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