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

frourioを使って1ヶ月で管理画面をリリースした話

はじめに

こんにちは、レバテック開発部の河村です。 私はレバテック各種メディアのリプレイスを担当しており、バックエンドを中心にフルスタック開発を行っています。

今回は管理画面のリリースで採用した、フルスタックフレームワークであるfrourioについて、frourioを採用した理由や使ってみて良かったこと、困ったことを紹介します。 この記事を通して、frourioのメリット、デメリットだけでなく、レバテック開発部ではどのような背景のもと、技術・アーキテクチャの選定を行っているのか、どれくらいのスピード感で開発を行っているのかをお伝えできればと思います。

なお、この記事ではfrourioにおける環境構築や使い方等の説明は割愛させていただきます。


開発背景・経緯

今回、開発する対象となった管理画面は、レバテックの各メディアで運用する記事やセミナー情報、エントリー情報を管理するものになります。

すでに管理画面として、PHP(CakePHP)をベースとした技術で作られているものを利用している状態でしたが、 PHP自体のバージョンも古く、利用しているライブラリなどを含めレガシーな状態になっており、開発速度、品質、運用、保守性が低下している状態でした。 また、レバテック組織全体でTypeScriptの標準化を進めており、管理画面のリニューアルが必須の状態でした。

管理画面のため、特別凝ったデザイン等は必要ありませんが、利用するロールやメンバーが多く、複雑な機能が必要となることもあり、管理画面のフレームワーク(ReactAdminやAdminBroなど)を利用してしまうと、機能の実装が困難になる可能性もあり、基本的には拡張性が高い状態、フルスクラッチで開発できる環境を構築する必要がありました。

さらに、事業側の方々の要望もあり、約1か月程度での本番リリース、稼働開始が必要だったため、 上記の背景、経緯を踏まえた上で開発速度が出せるようなフレームワークを選定する必要がありました。

このような背景、経緯があり、frourioを採用することにしました。 frourioを採用した理由については後ほど詳細を説明させていただきます。


frourioとは

frourioは一言で言うと、TypeScript製のフルスタックWebフレームワークで、詳細については、公式ドキュメントまたはGithubを参照してください。


frourioの特徴

frourioの1番の特徴は、フロントエンドからバックエンドまですべてTypeScriptでかつ型安全に開発できるところです。 以下の図が、frourioが内包しているフレームワークやORMになります。

上記の図の通り、frourioはTypeScriptをベースに利用できるフレームワークを内包しています。 フロントエンドだとNext.jsやNuxt.jsを選択できたり、ORMだとTypeORMやPrismaを利用することができます。 また、Node.js系のフレームワークでハイパフォーマンスと言われているfastifyも利用することができ、安定したパフォーマンスを出すことも期待できます。

その他にも以下のような特徴がありますが、本記事では割愛させていただきますので、気になる方はぜひ調べてみてください。

  • コマンド1発でフロントエンドからバックエンドの環境構築
  • Vercel社が開発しているReact Hooksライブラリ「SWR」に対応
  • 関数ベースでControllerを実装可能
  • 関数ベースでDI(Dependency Injection)の実現

frourioを採用した理由

frourioを採用した理由として、大きく以下の3つになります。

  • TypeScriptを利用して型安全に開発できる
  • チームとして利用したいフレームワークが内包されている
  • 自動生成により開発速度を大きく向上させることができる

上記の理由について、以下、詳細に説明させていただきます。

TypeScriptを利用して型安全に開発できる

frourioの特徴でもお伝えしましたが、frourioはフロントエンドからバックエンドまですべてTypeScriptでかつ型安全に開発することができます。型安全に開発することで、不正な動作を事前に防げたり、フロントエンドとバックエンドをつなぐ部分のInterfaceを明示的に宣言でき、フロントエンドからバックエンドのAPIを呼び出す際も安全にかつスムーズに呼び出すことができます。

例えば、以下のように、バックエンドのAPIのInterfaceを定義し、

export type Task = {
  id: number
  label: string
  done: boolean
}

export type Methods = {
  post: {
    reqBody: Pick<Task, 'label'>
    resBody: Task
  }
}

フロントエンドからInterfaceを参照しながら、安全にかつスムーズにAPIを呼び出すことができます。

await apiClient.tasks.post({ body: { label } })

ここで、バックエンドのAPIのInterfaceを定義しましたが、 frourioのルールにしたがって定義することで、ApiClient(APIのクライアント)部分を自動生成してくれます。 こちらの自動生成については、自動生成により開発速度を大きく向上させることができるの箇所で説明したいと思います。

チームとして利用したいフレームワークが内包されている

チームとして、以下のフレームワークを利用して開発工数をできる限り抑えることを目指していました。

  • フロントエンド
    • Nuxt.js
    • Next.js
  • バックエンド
    • TypeORM

まず、フロントエンドですが、frourioではNuxt.js、Next.jsともに利用することができます。 チーム内ではVue.js、Nuxt.jsの方が経験のあるメンバーが多いため、どちらを選定するかで迷いましたが、TypeScriptとの相性、チームメンバーのスキル向上、モチベーションアップを考慮した上で、Next.jsを選定しました。型安全に開発できることをメインに置いていることもあり、TypeScriptとの相性の良いReact.jsをベースとしたNext.jsを選定することが当然であるかもしれませんが、それ以上に、チーム内でReact.js、Next.jsの技術を身につけていきたいというモチベーションが選定において大きな判断材料になったかと思います。

ちらっと、TypeScriptとの相性について述べましたが、こちらの記事が参考になりますので、気になる方は参考にしてみてください。

また、バックエンド、主にORMになりますが、TypeORMを採用しました。 現在、レバテック全体で各種機能のマイクロサービス化が進んでおり、TypeORMを利用しているチームやメンバーが多いことから選定しました。個人的にはPrismaを使ったみたいという気持ちもありましたが、開発工数をできる限り抑えるということで、すでに知見が溜まっているTypeORMを選定しました。 これにより、バックエンドの開発は、今までとほぼ変わらず、frourioのお作法を覚えるだけで開発できる環境を整えることができました。

余談:CSSフレームワーク

フルスクラッチで開発する以上、UIを作成するために、管理画面のフレームワーク(ReactAdminやAdminBro等)と比べて、UIの実装にある程度の工数がかかってしまいます。なので、そこで、CSSフレームワークでReactと相性の良いMaterial-UIを採用しています。 さらに、管理画面においては、同じような画面、Componentの使い回しが多くなることから、Atomic Designの概念を採用して、再利用される可能性のあるComponentはできる限り共通化(atoms, moleculed, organismsを適用)し、UIの実装にかかる工数をできる限り削減しました。

自動生成により開発速度を大きく向上させることができる

frourioのお作法にしたがって開発することで、様々な部分を自動生成をしてくれて、開発工数を大きく削減でき、開発速度を向上させることができます。個人的に、特にフロントエンドとバックエンドをつなぐ部分、ApiClient(APIのクライアント)部分の自動生成がとても便利だったので、紹介させていただきます。

  • ディレクトリを作成するだけで、Controllerを自動作成してくれる
  • APIのInterfaceを定義するだけで、ApiClientを自動生成してくれる

ディレクトリを作成するだけで、Controllerを自動作成してくれる

frourioの機能を利用することで、バックエンドのController部分に該当する./server/apiディレクトリに対象のエンドポイント用のディレクトリを作成するだけで、必要なファイルを生成してくれます。

上記の機能を説明する前に、まず、frourioを使って環境構築した後のディレクトリ構成を紹介します。

frourioのディレクトリ構成

frourioのコマンドを使って環境構築を行った際の設定が以下になります。 Next.jsのcreate-next-appのようなコマンドがfrourioにもあるので環境構築は簡単にできます。

frourio環境構築時の設定1
frourio環境構築時の設定2

上記を行った結果、以下のようなディレクトリ構成が出来上がります。

.
├── README.md
├── aspida.config.js
├── components # React用のComponent
├── ecosystem.config.js
├── jest.config.ts
├── next-env.d.ts
├── package.json
├── pages # Next.jsのpagesに該当
├── public # フロントエンド用の静的ファイル
├── server # バックエンド
│   ├── $orm.ts
│   ├── $server.ts
│   ├── api # バックエンドのController部分に該当
│   ├── entity # TypeORMのEntity
│   ├── entrypoints # バックエンドのサーバーを立ち上げるための設定等
│   ├── index.js
│   ├── migration # TypeORMによって生成されたMigrationファイル
│   ├── ormconfig.ts
│   ├── package.json
│   ├── service # レイヤードアーキテクチャ的に言うと、アプリケーションサービスに該当
│   ├── static # バックエンド用の静的ファイル
│   ├── subscriber # TypeORMのSubscriber
│   ├── test # バックエンド用のテスト
│   ├── tsconfig.json
│   ├── types # バックエンド用の型定義ファイル
│   ├── validators # Service等で利用したいValidation用のDTOに該当
│   ├── webpack.config.js
│   └── yarn.lock
├── styles # CSS
├── test # フロントエンド用のテスト
├── tsconfig.json
├── utils # フロントエンド用のUtil
│   └── apiClient.ts
└── yarn.lock

正直、利用しないディレクトリ等も生成されてしまいますが、不要なものは後から取り除く形で問題ないかと思います。 (実際に、./styles./server/service./server/subscriberは不要だったので取り除いています)

また、1つのディレクトリ内でフロントエンド、バックエンドを管理しているために、一見モノリシックに見えますが、Interface(型定義)で繋がっていること以外は、フロントエンド、バックエンド、それぞれに別のpackage.jsonがあり、別のプロジェクトとして扱うことができます。ライブラリ等が依存し合ったりすることもなく、仮にバックエンドを別のものに差し替えたとしても、フロントエンドのAPIの呼び出し部分を変更するだけで可能になります。

./server/apiディレクトリにディレクトリを作成してみると

上記のディレクトリ構成の通り、./server/apiディレクトリがバックエンドのController部分に該当します。 補足ですが、frourioのコマンドで環境構築をすると、デフォルトでサンプルのコードが作成されます。 また、$がつくファイルはfrourio側で生成されるファイルになります。

frourioの自動生成機能1

こちらのディレクトリに、hogehogeディレクトリを作成して、バックエンド側をビルドすると以下のようになります。 ホットリロードでも生成されるので、実際にはサーバーを起動したまま作成していくことになるかと思います。

frourioの自動生成機能2

上記のようにファイルが生成されており、実際のファイルの中身は以下のようになります。 メソッド自体はサンプル的なものになっていますが、これだけでも自動生成してくれるだけで恩恵を得られるかと思います。

index.ts

export type Methods = {
  get: {
    resBody: string
  }
}

controller.ts

import { defineController } from './$relay'

export default defineController(() => ({
  get: () => ({ status: 200, body: 'Hello' })
}))

$relay.ts

/* eslint-disable */
// prettier-ignore
import { Injectable, depend } from 'velona'
// prettier-ignore
import type { FastifyInstance, onRequestHookHandler, preParsingHookHandler, preValidationHookHandler, preHandlerHookHandler } from 'fastify'
// prettier-ignore
import type { Schema } from 'fast-json-stringify'
// prettier-ignore
import type { HttpStatusOk } from 'aspida'
// prettier-ignore
import type { ServerMethods } from '../../$server'
// prettier-ignore
import type { Methods } from './'

// prettier-ignore
type Hooks = {
  onRequest?: onRequestHookHandler | onRequestHookHandler[]
  preParsing?: preParsingHookHandler | preParsingHookHandler[]
  preValidation?: preValidationHookHandler | preValidationHookHandler[]
  preHandler?: preHandlerHookHandler | preHandlerHookHandler[]
}
// prettier-ignore
type ControllerMethods = ServerMethods<Methods>

// prettier-ignore
export function defineResponseSchema<T extends { [U in keyof ControllerMethods]?: { [V in HttpStatusOk]?: Schema }}>(methods: () => T) {
  return methods
}

// prettier-ignore
export function defineHooks<T extends Hooks>(hooks: (fastify: FastifyInstance) => T): (fastify: FastifyInstance) => T
// prettier-ignore
export function defineHooks<T extends Record<string, any>, U extends Hooks>(deps: T, cb: (d: T, fastify: FastifyInstance) => U): Injectable<T, [FastifyInstance], U>
// prettier-ignore
export function defineHooks<T extends Record<string, any>>(hooks: (fastify: FastifyInstance) => Hooks | T, cb?: (deps: T, fastify: FastifyInstance) => Hooks) {
  return cb && typeof hooks !== 'function' ? depend(hooks, cb) : hooks
}

// prettier-ignore
export function defineController(methods: (fastify: FastifyInstance) => ControllerMethods): (fastify: FastifyInstance) => ControllerMethods
// prettier-ignore
export function defineController<T extends Record<string, any>>(deps: T, cb: (d: T, fastify: FastifyInstance) => ControllerMethods): Injectable<T, [FastifyInstance], ControllerMethods>
// prettier-ignore
export function defineController<T extends Record<string, any>>(methods: (fastify: FastifyInstance) => ControllerMethods | T, cb?: (deps: T, fastify: FastifyInstance) => ControllerMethods) {
  return cb && typeof methods !== 'function' ? depend(methods, cb) : methods
}

追加したControllerの場合でも、開発側は特別な設定を追加することなく、frourio側が自動に設定してくれます。 実際には、./server/$server.tsに自動で設定されますが、ファイルがけっこうな行数になってしまうので、ここでは割愛させていただきます。

APIのInterfaceを定義するだけで、ApiClientを自動生成してくれる

frourioでは、標準でTypeScript製のREST APIクライアントであるaspidaを利用して、バックエンドのAPIにアクセスすることができます。そのため、フロントエンド側に./utils/apiClient.tsが生成され、aspidaの設定が追加されています。

├── utils # フロントエンド用のUtil
│   └── apiClient.ts

apiClient.ts

import aspida from '@aspida/axios'
import api from '~/server/api/$api'

export const apiClient = api(aspida())

上記の通り、~/server/api/$api.tsを読み込んでいるのですが、 こちらがバックエンド側で定義したAPIのInterfaceを元に、自動生成されたClientの接続周りをラップしたものになります。 例えば、上記の例(hogehoge)の場合だと、~/server/api/$api.tsは以下のように生成されます。

/* eslint-disable */
// prettier-ignore
import { AspidaClient, BasicHeaders, dataToURLString } from 'aspida'
// prettier-ignore
import { Methods as Methods0 } from '.'
// prettier-ignore
import { Methods as Methods1 } from './hogehoge'

// prettier-ignore
const api = <T>({ baseURL, fetch }: AspidaClient<T>) => {
  const prefix = (baseURL === undefined ? 'http://localhost:39809/api' : baseURL).replace(/\/$/, '')
  const PATH0 = '/hogehoge'
  const GET = 'GET'
  const POST = 'POST'
  const DELETE = 'DELETE'
  const PATCH = 'PATCH'

  return {
    hogehoge: {
      get: (option?: { config?: T }) =>
        fetch<Methods1['get']['resBody']>(prefix, PATH0, GET, option).text(),
      $get: (option?: { config?: T }) =>
        fetch<Methods1['get']['resBody']>(prefix, PATH0, GET, option).text().then(r => r.body),
      $path: () => `${prefix}${PATH0}`
    }
}

// prettier-ignore
export type ApiInstance = ReturnType<typeof api>
// prettier-ignore
export default api

これらのfrourioの機能を利用することで、フロントエンドは特段、APIを呼び出すためにAPI用のClientを実装する必要がなくなります。バックエンドのAPIを実装した時点で、フロントエンドはAPIを呼び出して開発を進めることができます。


frourioを使って困ったこと

現段階でfrourioを使って困ったことをいくつか紹介させていただきます。

  • ディレクトリ構成が変更しづらい
  • 不要な設定が思ったより追加されてしまう

ディレクトリ構成が変更しづらい

frourioのお作法にしたがって開発することで、様々な部分を自動生成をしてくれる分、ある程度、frourioが用意したディレクトリ構成に従わないと動作しない機能がいくつかあります。ただし、ORMに関する設定はよしなに変更できたりします(例えば、TypeORMであれば、ormconfig.tsの設定を変更したりなど)

バックエンドを簡単なレイヤードアーキテクチャの構成(UI層→Application層→Domain層→Presentation層)にしようと思っても、上記で紹介した./server/apiディレクトリを変更するとControllerを追加できなかったり、./server/validatorsディレクトリをApplication層のDTOとして利用しようと思っても、./server/validatorsディレクトリ内に作成しないとclass-validatorが機能しなかったりします。

これらの原因は明確で、frourioが生成される$が付いたファイルをよしなに書き換えたりすることができないためです。 例えば、validatorsであればserver/$server.tsに自動で設定が作成され、以下のように設定されています。

$server.ts(一部抜粋)

...
// prettier-ignore
import * as Validators from './validators'
...

// prettier-ignore
const createValidateHandler = (validators: (req: FastifyRequest) => (Promise<void> | null)[]): preValidationHookHandler =>
  (req, reply) => Promise.all(validators(req)).catch(err => reply.code(400).send(err))

// prettier-ignore
const formatMultipartData = (arrayTypeKeys: [string, boolean][]): preValidationHookHandler => (req, _, done) => {
  const body: any = req.body

  for (const [key] of arrayTypeKeys) {
    if (body[key] === undefined) body[key] = []
    else if (!Array.isArray(body[key])) {
      body[key] = [body[key]]
    }
  }

  Object.entries(body).forEach(([key, val]) => {
    if (Array.isArray(val)) {
      body[key] = (val as Multipart[]).map(v => v.file ? v : (v as any).value)
    } else {
      body[key] = (val as Multipart).file ? val : (val as any).value
    }
  })

  for (const [key, isOptional] of arrayTypeKeys) {
    if (!body[key].length && isOptional) delete body[key]
  }

  done()
}

上記の通り、frourioの機能を存分に活用するには、frourioのルールにある程度従う必要があります。 ただ、frourioの機能、特に自動生成周りの機能は、開発スピード、コストにおいてとても効果を発揮しますが、バックエンドをきれいなアーキテクチャで作成したいようなニーズとはマッチしないかと思います。特に、今回はある程度簡単な機能が多いということもありfrourioを採用しているので、無理してアーキテクチャを整える必要はなく、特に大きな問題とはなっていません。

必要のない設定が思ったより追加されてしまう

frourioの公式に従って環境構築を進めると、たしかにコマンド1発でフロントエンドからバックエンドの環境構築ができますが、その分、様々な設定が追加されている状態になります。不必要な設定があると開発者内で認識齟齬につながる可能性があるので、開発において不必要な設定は取り除いておいたほうがいいかと思います。

例えば、バックエンドのサーバーを立ち上げるための設定をしているserver/entrypoints/index.tsでは、デフォルトの状態だと以下のようになっています。

index.ts

import 'reflect-metadata'
import path from 'path'
import Fastify from 'fastify'
import helmet from 'fastify-helmet'
import cors from 'fastify-cors'
import fastifyStatic from 'fastify-static'
import fastifyJwt from 'fastify-jwt'
import { createConnection } from 'typeorm'
import server from '$/$server'
import ormOptions from '$/$orm'
import {
  API_JWT_SECRET,
  API_SERVER_PORT,
  API_BASE_PATH,
  API_UPLOAD_DIR,
  TYPEORM_HOST,
  TYPEORM_USERNAME,
  TYPEORM_PASSWORD,
  TYPEORM_DATABASE,
  TYPEORM_PORT
} from '$/service/envValues'

const fastify = Fastify()

fastify.register(helmet)
fastify.register(cors)
fastify.register(fastifyStatic, {
  root: path.join(__dirname, 'static'),
  prefix: '/static'
})
if (API_UPLOAD_DIR) {
  fastify.register(fastifyStatic, {
    root: path.resolve(__dirname, API_UPLOAD_DIR),
    prefix: '/upload',
    decorateReply: false
  })
}
fastify.register(fastifyJwt, { secret: API_JWT_SECRET })
server(fastify, { basePath: API_BASE_PATH })

createConnection({
  type: 'mysql',
  host: TYPEORM_HOST,
  username: TYPEORM_USERNAME,
  password: TYPEORM_PASSWORD,
  database: TYPEORM_DATABASE,
  port: Number(TYPEORM_PORT),
  migrationsRun: true,
  synchronize: false,
  logging: false,
  ...ormOptions
}).then(() => fastify.listen(API_SERVER_PORT))

また、デフォルトの状態の環境変数は以下のようになります。

.env

API_SERVER_PORT=39809
API_BASE_PATH=/api
API_ORIGIN=http://localhost:39809
API_JWT_SECRET=supersecret
API_USER_ID=id
API_USER_PASS=pass
API_UPLOAD_DIR=upload
TYPEORM_HOST=localhost
TYPEORM_USERNAME=root
TYPEORM_PASSWORD=
TYPEORM_DATABASE=hogehoge
TYPEORM_PORT=3306

おそらく、初期段階ではファイルのアップロードの設定や認証・認可の設定は不要になるかと思います。逆に、必要になるCORSやTypeORMの設定がされているので、この部分は工数削減になります。ただ、このように様々な箇所にデフォルトで設定がされているので、不要な設定がないかは確認しておいたほうが良いかと思います(他にも、TypeORMの場合、デフォルトでSubscriberの設定が追加されています)


最後に

管理画面の開発にfrourioを採用し、必要最低限のみの機能にはなりますが、想像以上のスピードでリリースすることができました。 ですが、まだまだfrourioの機能を活かせていない部分があり、特にaspidaSWR(stale-while-revalidate)、validationの機能を拡充したり、機能、非機能要件として以下の機能を実装していく必要があります。

  • NextAuthを利用した認証・認可
  • レバテック内部の各マイクロサービスとの連携
  • メール配信、予約配信
  • 記事投稿のためのリッチテキストエディター
  • ...etc

今回の記事で説明しきれていないfrourioの特徴やfrourio以外で困ったこと(Next.js、TypeORM固有の課題など)、frourioを利用したシステムアーキテクチャ(AWS)、また、今後上記の機能を実装していくなかで得られた知見などをまた別の機会に共有できたらなと思います。

参考記事

実践! Typescript で DDD - マイクロサービス設計のすすめ

対象読者

  • マイクロサービス化を検討しており、実際に作る場合の構成を参考にしたい。
  • ドメイン駆動設計について、基本的な用語の知識がある。
  • TypeScript を多少触ったことがある。理解がある。

はじめに

こんにちは。エンジニアの吉村です。 現在、弊社が運営する teratail というサービスに携わっており、CakePHP で動作しているモノリシックな既存サービスをマイクロサービスに移行するというプロジェクトを進行中です。

この記事では、実務を通して得た知見として、マイクロサービス化によりどんな恩恵があるのか、具体的にどのような構成で実装をしているのかについてご紹介します。

TL;DR

マイクロサービスのバックエンドサービスの実装に焦点を絞って、ドメイン駆動設計 + オニオンアーキテクチャをベースに設計をしました。 本記事では、具体的に「ユーザ新規登録処理」の実装をする場合を例にとり、実装手順をなぞりながら詳細を解説しています。 結論としては、従来の MVC ベースのアーキテクチャと比較して、メンテナンス性が高く技術的負債が生じづらい、スケーリングが容易な構成にできたのではないかと思います。その反面、簡単な処理の実装であっても記述量が多くなってしまうため、初期コストは割と高めな構成であるように思いました。

なぜマイクロサービス化を導入したのか

モノリシックサービスとマイクロサービスとで、どちらの構成が優れているかについては、実装するサービスの規模や様々な背景などによって左右される部分ではありますが、 今回のケースでは

  • 比較的規模の大きいシステムであること
  • サービスの改善を更に速度を上げて実施すべく、スケールしやすい構成にしておく必要があること
  • 弊社の他マイクロサービスと相互連携をしやすい構成にする必要があったこと

などの理由から、マイクロサービスとしてリプレースをしていく方針となりました。

マイクロサービスでシステムを実装をしていくにあたって、初期設計の段階で失敗してしまうとメンテナンスが困難なシステムができあがってしまう恐れがあるため、アーキテクチャの検討は注意深く行っていく必要がありました。 様々な文献で構成を調べる上で、特に気をつけたポイントは、なるべく技術的負債を回避でき、処理の把握が容易でメンテナンス性が高く、ミドルウェア等の環境の変更に強いシステムとなることでした。

それら検討の上で現在どのような構成で実装をしているか、簡単な処理での例を基にご紹介します。

サービスの構成

サービス全体の構成としては、フロントエンドサーバ、BFFサーバ、バックエンドサーバ と分けて実装をしていますが、 今回はバックエンドサーバのサービス構成に焦点を絞ってご紹介していきます。

構成図

サービスの構成は、オニオンアーキテクチャ[1]を参考に、以下の図の構成としています。

サービスの根幹となるドメイン層を中心に置いて、アプリケーション層、アダプタ層、インフラストラクチャ層と順に囲っていき、 外側の層から内側の層への単方向の依存のみ許可しています。※図の右側の各モジュールの詳細については後述します。

マイクロサービス化をするにあたり、ドメイン駆動設計を参考に、一つのアプリケーションを、例えばユーザ のアカウントについて管理する user サービスや、投稿された内容を管理する post サービスなど、それぞれ独立して成立するサービスとして分割していきます。このときに分割されたサービスの粒度を1単位としてサブドメインと呼ぶことにし、サブドメインごとに上図の構成のサービスを実装していきます。

ディレクトリ構成

ディレクトリの基本的な構成は以下の通りです。 以下の例ではサブドメインが user のみの構成となっていますが、サブドメインの数だけここに同様の構成のものが並びます。 src/shared には共通のコアロジックや、ベースクラスなどを配置しています。

src
├── server.ts
├── subdomains
│   └── user
│       ├── Entities
│       │   └── User.ts
│       ├── Events
│       │   └── UserCreated.ts
│       ├── Infrastructures
│       │   └── typeorm
│       │       └── User.ts
│       ├── Mappers
│       │   └── UserMap.ts
│       ├── Repositories
│       │   ├── UserRepository.ts
│       │   └── implements
│       │       └── TypeOrmUserRepository.ts
│       ├── Subscriptions
│       │   └── AfterUserCreated.ts
│       ├── UseCases
│       │   └── CreateUser
│       │       ├── CreateUserUseCase.ts
│       │       ├── CreateUserDTO.ts
│       │       ├── CreateUserErrors.ts
│       │       └── CreateUserController.ts
│       └── ValueObjects
│           └── UserName.ts
└── shared
    ├── core
    │   ├── AppError.ts
    │   ├── Guard.ts
    │   ├── Result.ts
    │   ├── UseCase.ts
    │   └── UseCaseError.ts
    └── domain
        ├── events
        │   ├── DomainEvents.ts
        │   ├── IDomainEvent.ts
        │   └── IHandle.ts
        ├── AggregateRoot.ts
        ├── Entity.ts
        ├── Identifier.ts
        ├── UniqueEntityID.ts
        ├── ValueObject.ts
        └── WatchedList.ts

実際に「ユーザ登録処理」を作ってみる

ドメイン層の定義

まず、ドメイン層として ValueObject と Entity の定義をしていきます。 ドメイン層はサービスの根幹となる部分であり、すべての処理はこのドメイン層で定義されたものを操作する形で実装していきます。

ValueObject

ValueObject は、Entityを構成するプロパティとして使用したり、レイヤ間のやり取りをする箇所などで用います。 ValueObject を用いるメリットとしては、対象の値が正しい形であるということが担保できるという点と、その値に対してのビジネスルールを豊かに表現できるという点があります。 オブジェクトを生成する際に必ずバリデーションロジックを通すように作ることができるため、ValueObject としてデータが存在している時点で、形式的に正しい値を持っているということが保証され、様々な箇所で形式チェックをする必要がありません。 また、例えば Email という ValueObject を作成したときに、対象の文字列からドメイン部分のみ抽出するというメソッドを用意したりなど、対象の値に対して何らかの加工をした形で取得をするといったことをまとめて定義しておくことができます。

以下の例では、ユーザの名前を表現する UserName ValueObject を定義しています。 入力された値が 3 文字以上 32 文字以下の場合はエラーを返す、簡単なバリデーションロジックを組み込んでいます。

// src/subdomains/user/ValueObjects/UserName.ts
export interface UserNameProps {
  value: string
}

export class UserName extends ValueObject<UserNameProps> {
  get value(): string {
    return this.props.value
  }

  private constructor(props: UserNameProps) {
    super(props)
  }

  private static isValidName(name: string) {
    const re = /^.{3,32}$/
    return re.test(name)
  }

  public static create(name: string): Result<UserName> {
    if (!this.isValidName(name)) {
      return Result.fail<UserName>(`Invalid Argument - userName:[${name}]`)
    }
    return Result.ok<UserName>(
      new UserName({ value: name })
    )
  }
}

※ Result

Result クラスは、成功形か失敗形かの状態を持たせた結果データを表すものとして実装しています。 成功形の場合は成功した値を取得することができ、失敗形の場合はエラーレスポンスを取得できます。どちらの状態であるかは isSucceedisFailure のプロパティの値で判別可能です。 上記 ValueObject の生成メソッドでこの戻り値を利用していますが、状態ごとの処理を記述する可能性のあるあらゆる箇所で利用しています。

Entity

用意した ValueObject をプロパティとして設定し、Entity を定義します。 Entity は固有の識別子として id を持っており、 id が一致している Entity は、プロパティが異なっている場合でも同一のものとして扱います。

// src/subdomains/user/Entities/User.ts
interface UserProps {
  name: UserName
  createdAt: DateTime
  updatedAt: DateTime
  deletedAt?: DateTime
}

export class User extends Entity<UserProps> {
  get name(): UserName {
    return this.props.name
  }
  
  get createdAt(): DateTime {
    return this.props.createdAt
  }

  get updatedAt(): DateTime {
    return this.props.updatedAt
  }

  get deletedAt(): DateTime | undefined {
    return this.props.deletedAt
  }

  private constructor(props: UserProps, id?: UniqueEntityID) {
    super(props, id)
  }

  public static create(props: UserProps, id?: UniqueEntityID): Result<User> {
    const user = new User({...props}, id)
    return Result.ok<User>(user)
  }
}

アプリケーション層の実装

次に、定義されたドメイン層の部品を操作して、ユーザ作成処理を記述するアプリケーション層の UseCase、 DTO、 RepositoryInterface を実装していきます。

DTO

まず、 DTO(Data Transfer Object)を定義します。 DTO は、UseCase の入出力パラメータを表します。 UseCase がリクエストとしてどういう形式を求めているか、レスポンスとしてどんなデータが返るのかを明確に定義しておくことで UseCase の仕様を把握しやすくするなる他、UseCase を実行する際にはこの定義に従って呼ぶようにすればよいため、 通信プロトコルが何なのか、または DomainEvent (後述します)の後続処理として実行されるのかなど、実行元が何であるかなどを意識することなく UseCase を定義することができるようになります。

// src/subdomains/user/UseCases/CreateUser/CreateUserDTO.ts
export interface CreateUserRequestDTO {
  name: string
}

export interface CreateUserResponseDTO {
  success: boolean
}

UseCase

UseCase は、ドメイン層を操作してデータの加工をし、Repository にデータを渡す処理や、Repository から取得したデータを返却する処理を記述していきます。 UseCase は、できるだけ単一の操作にのみ責任を持つように作るほうが良いです。例えば、エンティティを新規作成する UseCase を作成する場合は、データ作成の成否のみを(必要であれば作成したエンティティのIDのみあわせて)返却するよう実装し、エンティティ全体のデータを取得する UseCase は別途作成するようにしておきます。 このように、データの操作のみに責任をもつ Command 処理と、データの返却にのみ責任を持つ(データを操作しない) Query 処理に明確に分けて実装しておくと、それぞれの処理の最適化に注力しやすくなり、データの読み出しに関してはデータに副作用を与えないことが担保された Query 処理を使うことで、安全にシステムを利用することができるようになります。※CQRS(コマンドクエリ責務分離)[2]

以下の例では、ユーザデータを作成を実行して、成功か失敗かの bool 値のみを返す Command UseCase を実装しています。

// src/subdomains/user/UesCase/CreateUser/CreateUserUseCase.ts
type Response = Either<
  | CreateUserAlreadyExistsError
  | CreateUserFailedToCreateUserError
  | UnexpectedError
  | BadRequestError,
  Result<CreateUserResponseDTO>
>

export class CreateUserUseCase
  implements UseCase<CreateUserRequestDTO, Promise<Response>> {
  constructor(
    private userRepository: UserRepository
  ) {}

  async execute(req: CreateUserRequestDTO): Promise<Response> {
    const nameOrError = UserName.create(req.name)
    if (nameOrError.isFailure) {
      return failed(new BadRequestError(nameOrError.errorValue().value))
    }

    const name = nameOrError.getValue()
    const now = DateTime.utc()
    try {
      if (await this.userRepository.isExists(name)) {
        return failed(new CreateUserAlreadyExistsError(name))
      }

      const userOrError = User.create({
        name: name,
        createdAt: now,
        updatedAt: now
      })
      if (userOrError.isFailure) {
        return failed(new CreateUserFailedToCreateUserError())
      }

      const user = userOrError.getValue()
      await this.userRepository.save(user)

      return succeed(
        Result.ok<UserCreateResponse>({
          success: true,
        })
      )
    } catch (err) {
      return failed(new UnexpectedError(err.message))
    }
  }
}

※ Either

UseCase のレスポンスは、Either クラスで表現しています。 UseCase の結果値として、複数の Result オブジェクトが返る場合がありますが、それぞれが正常系か異常系かを振り分けて定義しています。 これにより、 UseCase の利用側はレスポンスを判別が成功系か異常系か判別して処理を記述しやすくなるほか、UseCase の定義書のように機能し、どんなパターンのレスポンスが存在するか一目で分かるようになる利点があります。

UseCaseError

UseCase の異常系レスポンスとして、UseCaseError を定義します。

// src/subdomains/user/UesCase/CreateUser/CreateUserError.ts
export class CreateUserAlreadyExistsError extends Result<UseCaseError> {
  constructor(name: UserName) {
    super(false, {
      message: `User name already exists. - name: ${name.value}`
    } as UseCaseError)
  }
}

export class CreateUserFailedToCreateUserError extends Result<UseCaseError> {
  constructor() {
    super(false, {
      message: "Failed to create user."
    } as UseCaseError)
  }
}

RepositoryInterface

UseCase 実装時点で、Repository にアクセスする必要がある箇所を記述する場合は、Repository の実装を抽象化した RepositoryInterface を作成し、抽象化された Repository へアクセスする形で実装していきます。※依存性逆転の原則[3]

これをする理由は、UseCase は本来、データを永続化する仕組みが何であるか、ORMとして何を使っているのかなどは意識する必要がなく、永続化データへの読み書きさえできれば Repository の具体的な実装を気にしなくても良いためです。 また、Repository を抽象化せずに具体実装に対してアクセスするようにしてしまうと、アプリケーション層がそれより外側のアダプタ層に依存する形での実装となってしまい、内側の層が外側の層に依存してはならないというルールに反してしまうため、これを逆転させる目的があります。

// src/subdomains/user/Repositories/UserRepository.ts
export interface UserRepository {
  isExists(name: UserName): Promise<boolean>
  save(user: User): Promise<void>
}

データ永続化処理実装

UseCase 実装の際に定義した RepositoryInterface の定義に基づき、DB 定義とデータ永続化の具体実装をしていきます。 従来型の MVC による実装などに慣れていると、まず DB の定義をしてから UseCase 処理の実装をしていきたくなるかもしれませんが、今回のアーキテクチャではそれとは逆で、ドメイン層とアプリケーション層を実装したあとに DB 定義を行います。 Repository は アプリケーション層・ドメイン層の処理の中で生成・更新された Entity を永続化し、永続化されたデータを Entity として返すことにのみ責任を持つように実装していきます。 こうすることでサービスのコアとなるロジックが DB の定義や ORM などに密接に依存してしまうような作りを回避することができます。

DBスキーマ定義

TypeORM を使用する場合を例に以下のようにモデルを定義します。

// src/subdomains/user/Infrastructures/typeorm/User.ts
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn
} from "typeorm";

@Entity()
export class User {

  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @CreateDateColumn({
    type: "datetime",
  })
  createdAt!: Date

  @UpdateDateColumn({
    type: "datetime",
  })
  updatedAt!: Date

  @DeleteDateColumn({
    type: "datetime",
    nullable: true,
  })
  deletedAt?: Date
}

Repository 実装

// src/subdomains/user/Repositories/implements/TypeOrmUserRepository.ts
import { createConnection, getRepository } from 'typeorm'
import { User } from '../../Infrastructures/typeorm/User'
import { UserMap } from '../../Mappers/UserMap'

export class TypeOrmUserRepository implements UserRepository {
  async isExists(name: UserName): Promise<boolean> {
    const connection = await createConnection()
    const userRepository = getRepository(User)

    const user = await userRepository.findOne({ name })

    await connection.close()

    return !!user
  }

  async save(user: User): Promise<void> {
    const connection = await createConnection()
    const userRepository = getRepository(User)

    await userRepository.save(UserMap.toPersistent(user))

    await connection.close()
  }
}

Mapper 実装

Entity <=> 永続化データ間の変換処理を Mapper として実装します。

// src/subdomains/user/Mappers/UserMap.ts
export interface UserRawProps {
  id: string
  name: string
  createdAt: Date
  updatedAt: Date
  deletedAt?: Date
}

export class UserMap implements Mapper<User> {
  public static toDomain(raw: UserRawProps): User {
    const userNameOrError = UserName.create(new UniqueEntityID(raw.name))
    if (userNameOrError.isFailure) {
      throw new Error(userNameOrError.error)
    }

    const userOrError = User.create(
      {
        name: userNameOrError.getValue(),
        createdAt: DateTime.fromJSDate(raw.createdAt),
        updatedAt: DateTime.fromJSDate(raw.updatedAt),
        deletedAt: raw.deletedAt
          ? DateTime.fromJSDate(raw.deletedAt)
          : undefined
      },
      new UniqueEntityID(raw.id)
    )

    if (userOrError.isFailure) {
      throw new Error(userOrError.error)
    }

    return userOrError.getValue()
  }

  public static toPersistent(userEntity: User): UserCreationParam {
    return {
      id: userEntity.id.toString(),
      name: userEntity.name.value,
      createdAt: userEntity.createdAt.toJSDate(),
      updatedAt: userEntity.updatedAt.toJSDate(),
      deletedAt: userEntity.deletedAt?.toJSDate()
    }
  }
}

通信インターフェース実装

ここまででユーザ登録処理の UseCase の実装が完了しました。次にこれを呼び出すインターフェースの実装をしていきます。 ここでは gRPC で実装する場合を例に実装していきます。

protocol buffer 定義

syntax = "proto3";
package sample.user.command_user;

service CommandUser {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

message CreateUserRequest {
  string name = 1;
}

message CreateUserResponse{
  bool success = 1;
}

Controller 実装

// src/subdomains/user/UesCase/CreateUser/CreateUserController.ts
export class CreateUserController {
  constructor(
    private createUserUseCase: CreateUserUseCase
  )

  async createUser(
    req: CreateUserRequest,
    metadata?: Metadata
  ): Promise<CreateUserResponse> {
    const reqDTO = req as CreateUserRequestDTO
    const res = await this.createUserUseCase.execute(reqDTO)

    if (res.isFailed()) {
      const error = res.value
      switch (error.constructor) {
        case CreateUserAlreadyExistsError:
        case CreateUserFailedToCreateUserError:
        case UnexpectedError:
        case BadRequestError:
        default:
          return {
            success: false
          }
      }
    }

    return {
      success: true
    }
  }
}

これでユーザ登録処理の実装が完了しました。 gRPC で対象のインターフェースを呼び出すことで、DB に対象ユーザのデータが登録ができていることを確認することができます。

ユーザ作成直後に処理を実行したい場合

ユーザ登録処理が完了した直後、メールなどの通知を送信したりレポートデータを書き込みたいなどの後続処理を実装する必要が出てくるケースがあります。その場合は、既存の UseCase (今回でいうと CreateUserUseCase )に手を加えて実装はせず、別の UseCase として各処理を実装し、 DomainEvent を利用して処理を実行するのが望ましいです。

① CreateUserUseCase を実行 ② User エンティティを生成したときに UserCreated イベントを発行。データの永続化が完了した時点でイベントを発火させる。 ③ Subscription がイベントの発火を検知して、後続処理を実行する。

DomainEvent

User エンティティが生成される際に、対象の Entity 情報を EntityID でタグ付けした DomainEvent を発行します。 この時点では、 DomainEvent を発行しているだけで、まだ発火はしていません。

// src/subdomains/user/Entities/User.ts
- export class User extends Entity<UserProps> {
+ export class User extends AggregateRoot<UserProps> { 

    // ...

    public static create(props: UserProps, id?: UniqueEntityID): Result<User> {
      const user = new User({...props}, id)

+     // 新規作成時のみイベントを発行するため、 id が付与されていない場合に実行
+     if (!id) {
+       this.addDomainEvent(new UserCreated(user))
+     }

      return Result.ok<User>(user)
    }
  }

イベントの発火は、データの永続化の完了を検知したタイミングで実行します。 TypeORM の場合を例にとると、 Entity Listener を使って以下のように実装します。

// src/subdomains/user/Infrastructures/typeorm/User.ts
@Entity()
export class User {

  // ...

+ @AfterInsert()
+ dispatchAggregateEvents() {
+   const aggregateId = new UniqueEntityID(this.id);
+   DomainEvents.dispatchEventsForAggregate(aggregateId);
+ }
}

Subscription は、 DomainEvent の発火を検知して、DomainEvent 内の Entity 情報を使って後続処理を実行するように実装しておきます。その上で、 Subscription をサーバ起動時に常時監視状態にしておくことで、ユーザ登録完了後に後続処理を実行する実装の完了です。

おわりに

マイクロサービスのバックエンドサービスの実装に焦点を絞って、オニオンアーキテクチャをベースに設計をしました。 実際にこの構成を採用してシステムを実装した所感として、従来の MVC アーキテクチャなどの実装と比較して良かった点、気になる点をまとめると以下のとおりです。

良かった点

  • サービスのコアロジックを独立して実装できるため、ミドルウェアの変更など環境の変化に柔軟に対応可能。
  • 各実装レイヤーごとに明確に責務を割り振ることで、処理の流れを把握しやすい。
  • 一貫したルールでの実装を半ば強制することで、システムの規模が大きくなるにつれて処理が複雑になりすぎるということが起きづらい。
  • 機能の追加・削除が容易で、スケールしやすい。

気になる点

  • 簡単な機能であってもコードの記述量が多くなってしまうため、初期コストが高い。

冒頭で述べたように、運用面でのメリットは多くありますが、初期の実装にかかるコストは高いように思いました。 リリースまでのリソースに余裕があり、長期的に運用をしていく可能性のあるシステムには導入する価値があるように思いますが、スタートアップのような限られたリソースでとにかく早くリリースをしたいという場合は、慎重に採用の判断をしたほうが良い構成であると思います。

各項目の解説については詳細を割愛している部分も多々ありますが、全体像の大まかなイメージだけでも伝われば幸いです。

参考記事

能動的に価値を追求!レバレジーズのシステム開発

f:id:s-nagasawa-lvgs:20210707183141j:plain

こんにちは、レバテック開発部の長澤です。 タイトルの通り、今回は私の所属部署でのシステム開発について一部をご紹介します!

執筆の背景

私は現在4ヶ月目の中途入社の社員です。 まだわずかな期間ではありますが、すでにレバレジーズのシステム開発は前職までの経験と大きく違うことを実感しています。

転職前は、作成された仕様書にのっとり「機械的」に「工場」の様に開発することを求められていました。 どちらかといえばトップダウンでシステム仕様を決めることが多く、開発者の意見が採用されることは多くありません。 そのためか 「使われない新機能」 「報告するためだけのドキュメント」 「固定化した開発プロセス」 「負債を抱えたレガシー技術」 「形骸化した会議」 などなど、本質的ではない事象をよく見てきました。

そのような環境から一転、私は今とても充実して開発をしています。 なぜ充実しているのか?それは開発プロセスに秘密があります。 今回は「4つのレバレジーズ開発現場の特徴」を皆様にご紹介します!

1、本質の追求

  • 予定していなかった新技術もプロダクトの価値が向上するなら、開発途中に導入することも珍しくありません。
  • 開発時に発生した課題は対話をベースに議論を交わします。無駄なドキュメントは作りません。

2、フィードバック

  • 開発したサービスは利用者が多く、リリース当日の新機能でも利用者がその日に反応し、開発した達成感が得られます。
  • マーケティングチームから数値ベースでフィードバックがあり、利用状況の良し悪しから、開発観点からも改善施策の提案も行います。

3、高い専門性

  • リードエンジニアやDBスペシャリストも在籍しており、高度な技術について議論する事もあります。
  • DDD/クリーンアーキテクチャ、マイクロサービス、gRPC等、社内標準技術スタックに、モダンでより専門性を必要とする技術を採用しています。

4、戦略共有

  • 開発メンバー会議以外にも、マーケター・セールス・デザイナーを含めた部署横断でのプロジェクト進捗共有を毎週開催し、開発内容や優先度に対する認識をすり合わせています。
  • 仮説と根拠が伴った中長期戦略から、開発の方向性の納得感を得ることができ、自信を持った開発をしています。

最後に

いかがでしたでしょうか。 ご紹介した内容は取り組みのほんの一部ですが、プログラミングだけでは無く、様々な部署を通じてプロダクトを成長させていくために、広い視点で開発をしていることを感じていただけましたでしょうか。 システム以外の知識も必要とされる場合もあり、課題にぶつかることもありますが、乗り越えた際は、顧客志向やマーケティング等のシステム以外の観点についても自身の成長を実感しています。

レバテック開発部では、一緒にサービスを作り上げてくれる仲間を募集中です! ご興味のある方は、以下のリンクから是非ご応募ください。 https://recruit.jobcan.jp/leverages/

新卒エンジニアが1ヶ月かけてマーケティングを学んだ話

はじめに

 こんにちは。21卒エンジニアの田中、五十嵐、益子です。

 エンジニアの新入社員向け研修といえば、開発に関わる研修を中心に受けるのが一般的だと思います。レバレジーズでは、エンジニアもマーケティング職と同じプログラムでマーケティング研修を受けます。約1ヶ月間、マーケティングの基礎の学習から始まり、最終的には顧客理解に基づいた「重視すべきサービスが提供する価値の定義と改善施策」の提案を行いました。

 本記事では、実際に研修を受けた体験談を通じて、なぜレバレジーズのエンジニアはマーケティング研修を受けるのか、どのようなことを学ぶのか、配属後の業務にどのように活きているのかを紹介します。

顧客理解×エンジニアリング="いいサービス"

 レバレジーズにとってマーケティングとは、「顧客のニーズを満たすこと」であり、顧客に最適な解決策を提供することです。

 なぜレバレジーズのエンジニアは、マーケティングを学ぶのでしょうか。

 レバレジーズは、セールス・マーケター・デザイナー・エンジニアなど、さまざまな職種が社内にいるオールインハウスの組織です。職種の枠を超えたスピード感のあるコミュニケーションや連携を通じて、様々な事業を展開しています。その中で、顧客理解に基づいた"いいサービス"を作り上げるために、エンジニアはマーケターやデザイナーの考え方を理解した上で、密にコミュニケーションをとり、実践する必要があります。

 そのためにエンジニアもマーケティング研修を受けることで、マーケターが業務でどんなことをしているのか、どういった思考のプロセスが求められるのかを学びます。

こんなメンバーがマーケティング研修を受けました

田中

「新規の事業作りをしたい思いが強く、オールインハウス組織で、若いうちから職域を広げた働き方がしたいと考え、レバレジーズへの入社を決めました。マーケティング研修はすごく楽しみでしたが、正直どんなことをやるのか想像つきませんでした。」

五十嵐

「会社として急激に成長しており新規事業に携われる可能性が高く、他職種との関係性が密なため、求められるスキルの幅が広いと考えレバレジーズへの入社を決めました。マーケティング研修を受ける前は、研修が楽しみな気持ちが半分と、内定者インターンをしていたチームから離脱することに対する不安が半分ありました。」

益子

「エンジニアとして技術を大切にしながらも、マーケティングや事業開発まで職能を広げたいと思い、レバレジーズへの入社を決めました。学生時代は受託開発企業で働きつつ、マーケティングのゼミで事業計画書を作り、役員への事業提案もしていました。3人の中でも特にマーケティング研修を楽しみにしていたと思います。」

どんなことをしたのか

 次に、マーケティング研修の内容について紹介します。この研修では、レバレジーズのマーケターがどんな考え方や方法で業務に取り組んでいるのかを学びました。実務レベルで実際のレバレジーズの事業部のデータ分析を行い、データを元にペルソナ設計からUX(顧客体験)改善施策立案まで行いました。

 具体的には以下のプログラムです。

  • ロジカルシンキング研修
  • マーケティング概論
  • ビジネスモデル研修
  • UX研修
  • プロモーション研修
  • オウンドメディア研修
  • CRM研修
  • データ活用研修
  • プロセス研修

 これらの研修を受けた後に、最終アウトプットとして、レバレジーズの既存サービス改善施策提案を行いました。

 改善施策提案では、顧客が求めていることや提供している価値から、サービスとしての理想状態を自分たちで定義しました。そして、顧客インタビューの記録や実際のデータ分析を通じて、最適な顧客体験を提供するために何が足りないか、どのターゲットに対して施策を打つべきかを特定しました。

 顧客体験を考える際には、研修で学んだペルソナ設計やカスタマージャーニーマップ設計などのアプローチを活用。定義した顧客体験を実現するための施策を立案し、営業現場のヒアリングや期待できる効果検証・工数の見積もりなども行い、顧客の理想や現場の実情に即したアウトプットにこだわりました。

研修を受けて感じたこと

田中

 元々、エンジニアでも事業課題の解決やサービス改善施策立案をやりたいと思っていたので、マーケティングの基礎を学ぶ時間があることはすごく貴重な時間でした。研修では、より良いサービスやプロダクトを作るために顧客理解が大事なことを学びました。    現在は、「ハタラクティブ」というメディアの開発を担当していて、チームで顧客インタビューを実施し、顧客理解に基づいた改善施策を実行しています。

エンジニアの立場でも顧客インタビューに積極的に関わらせていただき、職種を問わずチーム全体で顧客のことを考えた改善施策を進めていくことに、レバレジーズの良さが表れていて、僕が目指していた職域を広げた働き方ができています。配属されたばかりですが、さらにマーケティングの思考を生かした施策提案などにも挑戦していきたいです。

五十嵐

 研修でマーケターの実務に近い経験をさせていただいたのは、とても貴重な経験で、たくさんのことを勉強させていただきました。僕は現在、新規開発の事業部に所属しています。新しいプロダクトを作る上で、まず顧客に対してどんな体験や価値を提供するかを考え、それを実現するためにどんな機能が必要かを定義する必要があります。

 最初にUXをどれだけ深く考えられるかがその後のプロダクトの価値を左右すると考えているので、新規開発でもUXを意識して業務に取り組んでいます。エンジニアリングだけでなく、幅広い知識を身につけて業務に望んでいきたいという、選考時に抱いていたことを実際に経験できています。今後、サービスがリリースされたら、マーケティング戦略が本格的に動き出すため、その際に、研修で学んだことを更に活かし、開発業務の枠を超えたエンジニアになれるように挑戦を続けていきたいです。

益子

 自分はエンジニアの枠を超えて、課題定義から戦略・戦術の策定、さらには事業開発まで関わりたいと考えていました。そんな自分にとって、社内のマーケターから社内で取り組むマーケティングを網羅的に学べる機会は、非常に貴重なものでした。

マーティング研修で得た知見は、既に業務にも活きていると感じています。開発業務において、各事業課題が設定された背景に意識が向くようになり、「仮説に対しての検証施策に対し、細かな変更に対応できる記述になっているのか」などの新しい視点を持つようになりました。    開発業務以外では、エンジニアリング以外の職域にも挑戦するために、顧客ニーズ・顧客行動の調査などの積極的な情報収集を始めました。まずは何を目的に、どのようなタスクが動いているのか、業務の現状を理解することから取り組んでいます。顧客ニーズの調査方法や顧客行動の調査方法は企業によって異なりますが、マーケティング研修で実務のプロセスで調査手法を学んだことで、スムーズに必要な情報を理解することができています。エンジニアとしての職域にとらわれず、マーケティングを含め、幅広い面から事業に貢献できるプレーヤーになるため、今後も積極的な取り組みを続けたいと思っています。

最後に

 3人とも配属先での業務も異なるため、研修で学んだことの活かし方が異なりますが、顧客体験や施策背景といった様々な視点を持って開発業務に取り組むことで、確実にそれぞれにとってプラスになっています。

今後は、サービスやプロダクトを利用する顧客について理解した上で、「いいサービス」の開発に取り組み、社会に影響を与えられる人材となるために、切磋琢磨して日々努力していきます。

 「マーケティングも学びたい、若いうちから職域を広げて将来的に、事業をリードするエンジニアを目指したい」と考えている方は是非レバレジーズで一緒に働きませんか。お待ちしています! https://leverages.jp/recruit/

GraphQL + Apollo Client + TypeScript + React で型安全なフロントエンド開発を実現した話

はじめに

こんにちは。レバレジーズ株式会社の大滝です。

私は、レバレジーズのHRテック事業部に所属し、新規SaaSサービスのフロントエンド開発を行っております。

今回は雑然としがちな新規開発、とりわけフロントエンド開発で避けたかった4つの課題を、技術的な観点から回避していった点を紹介したいと思います。

新規開発で回避したかった問題

私たちの開発は新規開発でしたので、できるだけ技術負債を作らないように、かつスピード感を持って開発を行う必要がありました。 そこでフロントエンド開発を行う上で回避したかったポイントがいくつかあります。

  1. バックエンドとフロントエンド間でAPI仕様確認と管理に時間がかかる
  2. 型安全ではない
  3. 画面によってコンポーネントのデザインがバラバラ
  4. 入力動作が遅い

また前提として、開発中のサービス全体がマイクロサービスアーキテクチャを採用しており複数のサービス間がGraphQLで通信されていると言う特徴がありました。

図1 マイクロサービスアーキテクチャ構成図

フロントエンドはBFF(Backends For Frontends)に接続し、BFFではバックエンドのマイクロサービスのAPIの集約を行っています。

問題の回避方法と技術選定

上記した問題をクリアするには適切な技術選定を行う必要がありました。

しかし技術選定の難易度が高かったため、弊社のテックリードや開発メンバーと協力し調査を行いました。

結果的に下記の他のマイクロサービスで使用している技術と近く、かつ社内ナレッジがある程度蓄積されていると言う観点から、Apollo Client(graphql-codegen)/TypeScript/Reactを採用し、フォームライブラリとして、React Hook Formを利用しました。

これらの技術により、1の課題に対して、フロントエンドはBFFのエンドポイントからschema(APIの型定義)を取り込みそこからコードを生成することで回避しました。 また、schemaから生成したコードをもとに静的型付き言語であるTypeScriptを用いて実装を行うことで2の課題を回避しました。

3の課題に対しては、デザインの再利用性を高められるようにAtomic designを採用し、それに相性の良いReactを用いました。 さらに、動作速度向上のためにReact Hook Formという依存関係が少なく、軽量なライブラリを用いることで動作速度を向上させることで4の課題を回避しました。

画面実装までのフロントエンド開発フロー

上記の課題をクリアした実際の開発の様子を紹介します。 実際の開発では下記のようなフローで開発を行っております。

図2 実際の開発の手順

この開発フローに沿って、下記の画像のような簡単なユーザーの住所を変更する画面を実際に作ってみます。

図3 ユーザーの住所を変更する画面

GraphQL schemaの実装

サンプルのGraphQLスキーマを用意しました。 今回取り込むschemaはこちらです

type User {
  firstName: String 
  lastName: String 
  address: String 
}

type Query {
  user:  User
}

氏名、住所を持っているUser情報を取得するQuery型に入れます。 今回はサンプルなので1名分のUserを取得する形にします。

フロントエンドでのschemaの取り込み

次に、このスキーマをフロントから取り込みます。 まずはQuery情報を記載するgraphqlファイルを作成します

query userSearch {
  user {
    firstName
    lastName
    address
  }
}

これをGraphQL Code Generatorという機能を使用して、上記のgraphqlファイルのスキーマ情報を取り込みます。 GraphQL Code Generatorはcodegen.yamlにエンドポイントやgraphqlファイルのディレクトリ等を記載してスキーマ情報を読み込みます。

React Hook Formを用いたFormの実装

取り込んだスキーマを使用できるFormを実装します。 React Hook Formを用いてテキストボックスを実装してみます。

今回はMaterial-UIのMuiTextFieldを使います。

textFields.tsxに下記のようにMuiTextFieldをReact Hook Formでラップします。

仕様としてはFormのデフォルト値、ヘルパーテキスト、エラーメッセージが表示でき、nameをキー、入力値をバリューとしてsubmitできるものとしておきます

export type FormTextProps = TextFieldProps & {
  name: string;
  defaultValue?: string;
  showError?: boolean;
  rules?: Exclude<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
};

const TextFields: React.FC<FormTextProps> = ({
  name,
  rules,
  defaultValue = '',
  error,
  showError = true,
  ...textFieldProps
}) => {
  const { control, errors } = useFormContext();

  return (
    <>
      <Controller
        control={control}
        name={name}
        defaultValue={defaultValue}
        rules={rules}
        error={!!(get(errors, name) || error)}
        render={({ onChange, onBlur, value }) => (
          <MuiTextField onChange={onChange} onBlur={onBlur} value={value} {...textFieldProps} />
        )}
      />
      {showError && (
        <ErrorMessage
          errors={errors}
          name={name}
          render={({ message }) => (
            <FormHelperText error={true}>
              {message}
            </FormHelperText>
          )}
        />
      )}
    </>
  );
};
export default memo<FormTextProps>(TextFields);

コンポーネントを実装する際のポイントですが、下記の5点を意識しています。

  • Material-UIのTextFieldPropsの型定義を拡張してReact Hook Formで扱いやすくする。
  • nameタグはReact Hook Formでsubmitした際のkeyにあたるので必ずpropsとして注入するように必須にする。
  • rulesはReact Hook FormのRegisterOptionsの型定義から必要なものを集めてくる。
  • defaultValueは指定していないとwarningになるので空文字を初期値として設定する。
  • メモ化して無駄なレンダリングを減らす。

Form値に入力した値の表示テスト

最後に新住所を入力して入力値をconsoleで確認できるところまで作ってみます。

ユーザーの氏名を表示して、住所を新しく登録する画面を作成していきます。

見栄えをよくするためにスタイルも当てていきます。

const SamplePage: React.FC<{}> = () => {
  const methods = useForm<{ testTextFields: string }>({
    mode: 'onBlur',
  });
  const { handleSubmit, getValues } = methods;

  const onSubmit = () => {
     console.log('submit:', getValues());
  };

  const { loading, data } = useUserSearchQuery();

  useEffect(() => {
     console.log(data);
  }, [data]);

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        {!!loading && <>loading...</>}
        {!!data && (
          <>
            <Box display="flex" justifyContent="center" mb={2} mt={2}>
              ユーザーの住所情報を変更してください
            </Box>
            <Grid container alignItems="center" justify="center">
              <Grid item xs={8} style={{ backgroundColor: '#668bcd0f' }}>
                <Box m={2}>
                  <Grid container justify="center">
                    <Grid item xs={4}>
                      姓: {data?.user?.firstName}
                    </Grid>
                    <Grid item xs={4}>
                      名: {data?.user?.lastName}
                    </Grid>
                    <Grid item xs={8}>
                      現在の住所: {data?.user?.address}
                    </Grid>
                  </Grid>
                </Box>
              </Grid>
              <Grid item xs={6}>
                <Box mt={4}>
                  <TextFields
                    name={'newAddress'}
                    label={'新住所'}
                    rules={{
                      required: {
                        message: 'この項目は必須です',
                        value: true,
                      },
                    }}
                    defaultValue={data?.user?.address || ''}
                    helperText={'新しい住所を入力してください'}
                    variant={'outlined'}
                    fullWidth={true}
                  />
                </Box>
              </Grid>
            </Grid>
            <Box mt={3} display="flex" justifyContent="center">
              <Button type="submit" variant="contained" color="primary">
                更新
              </Button>
            </Box>
          </>
        )}
      </form>
    </FormProvider>
  );
};

export default SamplePage;

APIで取得したデータを表示する際は、codegenでgenerateしたファイルからAPIをfetchするuseUserSearchQueryをインポートして使用します。

ここで使用しているqueryのhookはGraphQL Code Generatorにtypescript-react-apolloのpluginを入れて生成されるもので、手間のかかるAPIのエラーのハンドリング部分の実装をせずにhookをimportするだけですぐにAPIを使用することができます。

useUserSearchQueryの実態をgenerateされたファイルで確認してみます

export function useUserSearchQuery(baseOptions?: Apollo.QueryHookOptions<UserSearchQuery, UserSearchQueryVariables>) {
        const options = {...defaultOptions, ...baseOptions}
        return Apollo.useQuery<UserSearchQuery, UserSearchQueryVariables>(UserSearchDocument, options);
      }

UserSearchQuery型がGenericsで渡されているので戻り値は型安全になっています、また使用する際はスニペットが効くのでかなり開発しやすいです。

空の状態でsubmitした際にはバリデーションがかかり、onBlurでもバリデーションがかかるように実装しています。この時にuseFormにGenericsで渡したFormの型がReact Hook Formに登録されます。

今回はMaterial-UIのBoxとGridを用いて画面を実装しましたが、これによりレスポンシブにも対応できる作りになっています。

まとめ

簡単ではありますが、新規開発等でも型安全にかつスピード感を持って開発できるような開発手法を紹介いたしました。

このように、GraphQLのスキーマから型情報を取得しTypeScriptとReactを用いて型安全な実装ができる上に、React Hook Formを用いることで簡単にFormの値の制御が行うことができるので非常に使い勝手が良いです。

HRテック事業部では一緒にサービスを作ってくれる仲間を募集中です!ご興味を持たれた方は、下記リンクから是非ご応募ください。 https://recruit.jobcan.jp/leverages/

Slack APIとLambdaの仕様による板挟みを回避した話

はじめに

こんにちは。レバレジーズ株式会社エンジニアの原田です。

私は、レバレジーズのシステムマネジメントチームに所属し、社内の業務改善のため、さまざまなWebサービスの導入や社内ツールの開発を行っています。

例えば、SlackとDocBaseのWebサービス同士のグループを同期させるツールを開発しました。いくつか問題が起きたことがあったので、どうやって対策したのかを紹介させていただきます。

DocBaseとは

DocBaseは気軽に書き込めるナレッジ共有サービスで、弊社では毎日数百件ナレッジが作られ共有されています。 このナレッジの閲覧権限はグループで管理することができ、ユーザーをグループに参加させることで簡単にアクセス権を管理することができます。

同期ツールとは

同期ツールは、AWSのLambda上で動作し、下記のイベントでグループの作成やリネーム、グループ参加者の管理を自動で行うツールです。

  • Slackチャンネルが作成された
  • Slackチャンネルがリネームされた
  • Slackチャンネルに誰かが参加した
  • Slackチャンネルから誰かが退出した

このイベントをもとに、Slackチャンネルと同名のグループをDocBase上に用意します。その後、Slackチャンネルに参加しているメンバーをグループ参加者として追加する動作を行います。

ただし、稀にSlackからのイベントを取得できないことがあり、「ナレッジを閲覧することができない」お問い合わせが発生することがありました。そのため、定期的にSlackチャンネルの情報をDocbaseに一括して同期するバッチ処理を追加で作ることにしました。

バッチ処理の内部動作

当初、バッチ処理は以下の図のように動作させることを考えていました。

早速バッチ処理用のLambdaを作成し、Slack APIを使って実装を行いました。 動作確認のためテストを行ったところ、次のような問題が発生しました。

  • 一定期間内におけるSlack APIの実行回数上限を上回ってしまう
  • Slack APIの実行回数上限を超えないようウエイト処理を挟むと、Lambdaの実行時間上限を超えてしまい処理が中断される

この時上限に達することを想定していなかったため、どのように問題を解決すれば良いかとても困った記憶があります。

なぜ上限に達したのか

Slack APIには毎分実行できるAPIの実行回数が設定されており、それを超えると429エラーが返ってくるよう設計されています。 なのでAPIを実行した後に2 ~ 3秒のウエイト処理を実行することでこの実行回数上限は回避できる、という仕様が存在します。

また、Lambdaは15分以上実行させようとするとタイムアウトしてしまい、処理が中断してしまうという仕様が存在します。

今回の追加開発では全Slackチャンネル情報が必要になるため、Slackチャンネル数分APIを実行する必要がありました。 この時、APIの実行が必要な回数は3,000回を上回っており、ウエイト処理を実行させると15分以上処理に時間がかかるため、Lambdaが途中で処理を中断させてしまうのです。

どのように解決したか

Slack APIとLambdaの仕様をチームメンバーに伝え、どのようにこの問題を解決するか相談したところ「1度にまとめてやろうとせず、処理を分割して行う」方針で解決する話になりました。

処理を分割すれば、Lambdaの実行時間上限を超えないようSlack APIを実行できるのでSlack APIとLambdaの仕様どちらも解決可能です。

こうして、同期ツールのバッチ処理開発を行うことができ「記事が閲覧できない」というお問い合わせを大きく減少させることができました。

もし、同じように困っている方がいましたら、参考にしていただけますと幸いです。

まとめ

今回の問題に遭遇したことで、予め上限や制約などがないか調べる癖を付けると良いなと実感しました。

レバレジーズでは、業務上の問題や課題は、一人ひとりの問題ではなく、チームメンバー全員の問題や課題として扱うことで自然と知見を共有できるため、すぐに問題解決が行えます。

システムマネージメントチームでは一緒にレバレジーズを支えてくれる仲間を募集しています!ご興味を持たれた方は、下記リンクから是非ご応募ください。
https://recruit.jobcan.jp/leverages/

チームブレストから8言語検索のコスト削減とUX最適化を両立させた話

はじめに

レバレジーズ株式会社エンジニアのカラバージョ(Caraballo)です。今回は、8言語(*1)で求人情報を提供しているメディアであるWeXpats Jobsで実装した多言語検索のコスト最適化についてご紹介します。

(*1) 2021年2月現在。

なぜコストの最適化が必要だったのか?

チームの目標として、ユーザーエクスペリエンス(UX)を向上させるために日本語で書いてある求人情報を複数の言語で検索できるようにする必要がありました。

私たちのチームでの最初のアプローチは、Google translate APIを使用して各求人情報を翻訳し、Elasticsearchにインデックスを付ける予定でした。これは簡単なアプローチのようにみえますが、APIの費用が100万文字あたり$20USDであり費用対効果が低いことに気付きました。 月額だと約 $4,000USDの費用がかかる計算です。

この問題にどのように取り組んだのか?

まずはじめに、ブレインストーミングを行い問題を根本的なものに集約しました。つまり、「日本語のテキストデータを元にして他の言語での検索を効率的に行う方法は何か」ということです。

たとえば、次のテキストのように 「東京でReactを使用したフロントエンドエンジニアとしての職務」の中から、仕事を探すという文脈で意味を伝えたい重要な部分は [東京、React、使用、フロントエンドエンジニア、職務 ] の名詞であり、[で,を,した,としての]を省略しても他の言語に翻訳したときに元のテキストの意味をほとんど反映できます。

したがって、各求人情報の名詞を抽出して翻訳することで翻訳の必要があるAPI呼び出しの数が減る、さらに記事の間で何度も使われている名詞の翻訳結果をキャッシュすることでさらに翻訳の数を減らすことができました。 その結果、翻訳されるデータが多くなるほど、辞書が増え必要なAPI呼び出しが少なくなっていきます。

計画は次のとおりでした。

  1. 日本語のテキストをtokenizeし、名詞のみを抽出する。
  2. 抽出した名詞を共通の辞書に保存し、必要に応じて各言語に翻訳を追加する。
  3. 翻訳された名詞を準備して、Elasticsearchの各言語のインデックスを作成する。

実装

上記の要件を実装するために、今回はGolangを利用しました。

  1. 日本語の原文を取り込む
  2. トークナイザーを使用して名詞のみを抽出する
  3. 新しい単語がある場合、辞書に保存する
  4. 翻訳されていない単語を翻訳する
  5. 複数の言語でのテキストBLOBを作成する
  6. Logstashを使ってElasticsearchにデータを取り入れてインデックスを作成する

日本語をトークン化するために、次のgolangライブラリを使用しました。

github.com/ikawaha/kagome/tokenizer
Kagome Japanese Morphological Analyzer(https://github.com/ikawaha/kagome/tree/master)

例:
package main

import (
    "fmt"
    "github.com/ikawaha/kagome/tokenizer"
)

func main() {
    nouns := GetTokens("東京でReactを使用したフロントエンドエンジニアとしての職務")
    fmt.Printf("%v", nouns)

}

func GetTokens(content string) []string {
    t := tokenizer.New()
    tokens := t.Analyze(content, tokenizer.Normal)
    var nouns []string
    for _, token := range tokens {
        if token.Class == tokenizer.DUMMY {
            continue
        }
        feature := token.Features()[0]
        switch feature {
        case "名詞":
            nouns = append(nouns, token.Surface)
        }
    }
    return nouns
}

output

[東京 React 使用 フロントエンドエンジニア 職務]

まとめ

今回は、多言語検索のためのコスト最適化の例をご紹介しました。 翻訳された名詞の辞書を作成することで、API呼び出しの数を大幅に減らすことができ、将来的にはWeXpats Jobs以外のサービスでも多言語検索サポート機能を使用できるように拡張していく予定です。

レバレジーズ株式会社では現在、サービスを開発し、優れたユーザーエクスペリエンスを作成するための新しい仲間を探しています。興味のある方は、こちらのリンクに応募してください。
https://recruit.jobcan.jp/leverages/

設計書メンテで消耗しないためにIDL開発へ切り替えた話

はじめに

こんにちは。レバテック開発部の園山です。私は、レバテック開発部のビジネスサポートグループに所属し、システム開発業務を担当しています。

本記事では、開発効率を向上するためにインターフェイス定義言語 (IDL) ベースの開発に切り替えて、設計書管理を行わず、どのように開発を行っているかについてご紹介します!

レバテックにある主な3つの機能として、「営業支援・管理ツール」「ユーザーや取引先企業が使用する登録者向けサービスシステム」「オウンドメディア」があり、現在、それらを1つの統合化したプラットフォームに集約するプロジェクトにおいて業務設計・開発を進めています。

今回のリプレイスにあたり、システム開発をより良いものにするためにはどうしたら良いかメンバー間で日々意見を出し合っており、そこで生まれた案をひとつずつ取り入れています。その中のひとつとして、ドキュメントで設計書を管理する体制を廃止しました!

これまでのやり方

これまでは、設計者が設計書をドキュメントに起こし、ドキュメントに書かれたものを実際の開発担当者が読み、開発を行っていました。複数の施策が同時並行で進んでいることが多く、仕様を更新する場合は設計担当者が設計書のコピーを作り、仕様を更新して、差分がわかるように打ち消し線や文字列の色を変える工夫をしていました。 結果、原本のドキュメントのメンテナンスが後回しになってしまったり、特殊仕様の認識合わせにコミュニケーションコストがかかっていました。

インターフェイス定義言語 (IDL) ベースの開発とは

インターフェイス定義言語 [IDL: Interface Definition Language](以下、「IDL」という) とは、特定のプログラミング言語とは別にオブジェクトのインターフェイスを指定するために使用される汎用言語のことで、本プロジェクト内では gRPC のプロトコルバッファーから IDL を使用しています。 プロトコルバッファー以外にも、OpenAPI(Swagger)、GraphQLや、Apache ThriftなどがIDLでSchemaを定義する技術になります。

プロトコルバッファーIDL は、オープンな仕様を持つプラットフォームに依存しないカスタム言語で、開発者は、入力/出力共にサービスを記述する .proto ファイルを作成し、API仕様を決めることができます。 これらの .proto ファイルはクライアントとサーバーの言語を生成できるので、TypeScript ⇄ PHP といった複数の異なるプラットフォームで通信でき、開発時にも.protoファイルを共有することで、コードの依存関係を取得することなく他のサービスを使用するためのコードを生成することが可能になっています。

IDLベースの開発は社内でも前例がありませんでしたが、1人が起案したところから始まり、サンプルコードを元に勉強会を実施して、実際に開発を進めながら習得していきました。 複雑な責務を持つマイクロサービスの場合、事前にある程度知識を深めるための資料を用意して、関係者間で認識合わせをしてから着手することもありましたが、基本はIDLベースで問題なく進めることができています。

違いを比較してみると

実際の開発シーン別にその違いを比較してみます。

開発あるある①:仕様が途中で変更になる
既存 設計者が設計書を更新 → 開発者が設計書を見て実装を修正
IDL 設計/開発者がIDLを更新 → 開発者はコード生成を再実行し、コンパイルエラーが発生していたらエラーを解消

開発あるある②:開発を分担していて連携部分が心配
既存 片方の実装が終わるまで動作確認ができず、実装完了後に不具合が見つかったりする
IDL IDLからコードを生成するため、定義通りのスキーマになることが保証され、連携部分の心配がなくなる

開発あるある③:設計書の管理が難しい
既存 施策単位や特殊仕様に応じてドキュメントが増えがちで、設計書をメンテナンスする優先順位が低く管理が行き届きづらい
IDL IDLから設計書を出力する(手動で差分を最新化する手間がなくなりました)

以前よりも変化を迅速にシステムへ反映させていくことができるようになりました!

やってみた感想

設計者と開発者の垣根がなくなったことが大きく、開発をしながら改善していく楽しさを実感しています。 デメリットは、慣れるまでの学習コストやある程度の設計スキルが必要なことで、I/Fだけでは見えない仕様パターンの考慮をどのようにメンバー間でコミュニケーションを取りながら進めていくかなど、チームルールを整備する必要がある点も課題に感じています。

まとめ

今回は開発効率を向上するためにインターフェイス定義言語 (IDL) ベースの開発に切り替えたことについてご紹介しました。IDL開発について何か1つでも参考になる点があれば幸いです。社内では、デメリットに挙げた点についても改善などの提案が常に行われており、解決へ向けて積極的に取り組んでいるため、また別の機会にご紹介できればと思います。

レバテック開発部では、一緒にサービスを作り上げてくれる仲間を募集中です! ご興味のある方は、以下のリンクから是非ご応募ください。 https://recruit.jobcan.jp/leverages/