全文検索 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