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/

プレイド社とマイクロサービス勉強会を開催しました~コロナ禍でも楽々!クローズド+リモート勉強会のススメ~

こんにちは。レバレジーズ株式会社のテックリードの竹下です。

2021/1/13に、KARTEのサービスを運営しているプレイド社とマイクロサービスに関して、合同勉強会を開催しました。

今回は、クローズドかつ、リモートで勉強会を開催したため、リアル開催やリモート一般公開と比べてどのようなメリットがあったかをご紹介していきます。

目次

開催の経緯

これまでレバレジーズでは、社内勉強会に加え、セミナールームを勉強会に貸し出すことで、社外とも交流を持ち、エンジニアが学べて成長できる環境を作ってきました。しかし、コロナ禍によって勉強会が全てリモート開催になったり、延期になってしまったことで、勉強会の機会が減っていました。

社内での勉強会は、社内の知見共有が中心となってしまうため、世の中の技術トレンドや、他社での取り組みを知る機会は多くありません。自社でリモート勉強会を開催しようと考えましたがレバレジーズは技術的な知名度がまだ低く、集客は難しいと判断しました。(開催したのはいいけど、社員しか参加してくれなかったら悲しいじゃないですか……😥)

一般公開では集客が難しそうなので、TypeScriptでマイクロサービス化という技術スタックをすでに運用しているプレイド社に「2社で勉強会をしませんか」と声をかけたのが開催の発端です。

勉強会の内容

勉強会のタイムスケジュールは、「マイクロサービス」をテーマに15分程度で発表を2社2名づつ行い、その後に懇親会という流れで行いました。 それぞれのタイトルと資料は下の通りです。(敬称略、公開確認取れたもののみ掲載しています)

  1. レバレジーズ株式会社 住村 「マイクロサービス五里霧中」
  2. 株式会社プレイド 大矢「Tilt.dev を使ったリモート k8s 開発環境」
  3. レバレジーズ株式会社 竹下 「開発効率爆上げを目指したインフラ技術スタック構想」Slide@Prezi
  4. 株式会社プレイド 山内「アンチパターンから学ぶマイクロサービス」

私の発表に関しては詳しい内容は、また後日改めてブログに書きますので、ご期待ください

クローズドで内容が充実する

業務に密接に関係する話ができる

クローズド開催にすることで、関係者は2社のみとなるため、お互いが興味のあるテーマの勉強会を開催することが可能になります。一般公開の勉強会を行う場合、集客性や、世の中のエンジニアの人に役に立つような内容にすることを考慮する必要があるため、幅広い人に興味を持って貰えるようなテーマ設定になりがちです。

今回プレイド社とは、「TypeScript」と「マイクロサービス」という2つの共通点があり、マイクロサービスに関する勉強会を開催する運びになりました。

踏み込んだ話ができる

クローズド開催になったことで、内容に関しても踏み込んだ内容まで発表することができました。一般公開した場合、その分野に詳しくない人も来ることが想定されるため、その人達にもわかるような発表をする必要があり、どうしても本題に入るのが遅くなってしまいます。

しかし、2社間だと前提とする知識が共通してあるため本題の説明に時間を多く割くことができました。(そのため、発表スライドだけを見てもらうと端折られているように感じる部分があるかもしれません。)

さらに、発表内容自体も「開発環境をどう作っているか」や「どんな失敗をしたか」「どういう挑戦をしているか」など、一般論に終始しない実務に根ざした内容が盛りだくさんになっていて、普通の勉強会ではなかなか聞くことの出来ない内容になっています。

懇親会も濃密

勉強会では発表も重要ですが、懇親会も発表を補完する機能を持っています。クローズドだとお互い実務に携わっている人が多く参加しているので、発表者の人に話を聞くだけでなく、他のエンジニアに実務の中でのノウハウを聞いたり、あるある話に花を咲かせることも可能です。また、プロジェクトマネージメントや気になっている技術のことなど、普段なら相手のバックグラウンドを探ってからでないと聞きにくいようなことも聞きやすく、勉強会のテーマ以上のことを学ぶことも出来ました。

開催者も参加者も楽できる

周知や参加者管理が楽

私は前職で隔週でScala勉強会の会場係を7,8年務めていたりScalaMatsuriの運営を5,6年手伝っていましたがが、参加者を集めたり内容を企画するのにいろいろ苦労をしてきました。connpass, atendなどのイベント告知サイトにイベント登録をしたり、発表を募ったり、キャパ制限があれば先着順や抽選をしたり、リアル会場なら会場への入場方法を案内したり、懇親会の店をとったりと様々な雑務が必要です。

しかし、クローズド+リモートにすることでそれらの手間がかなり軽減されました。周知はイベントサイトなど作る必要が無く、お互いの会社でSlackやメーリスで流すだけとなり、全員リモート参加なので人数制限や会場への案内も不要で、懇親会のお店の予約や準備もいりませんでした。そのため、勉強会の開催ノウハウや人員がいなくても手軽に開催が可能になります。今回は、初回ということもあり入念に準備しましたが、それでもミーティングが約1時間半くらいと、接続テスト30分程度で準備が出来ました。

勉強会を継続するには、勉強会の運営や管理をしていく人が必要になりますが、これぐらいの労力なら片手間に出来るので、ひとりでも継続して開催することができます。

リモートなので参加者が楽

リモートになったことで参加者も楽になっています。リアル開催の場合、会場へ移動する必要があるためどうしても参加障壁が上がります。今回は、19時開始でしたが、もしプレイド社(銀座本社)で開催した場合、レバレジーズは渋谷に本社を構えているため、遅くとも18時30分には退社し会場に向かう必要があります。懇親会含めると22時30分くらいまでやっていたため、家につくのは23時を過ぎてしまいます。レバレジーズ社とプレイド社ならまだ近いですが、大阪や福岡にある会社の場合は、そもそも参加すらできません。

しかし、リモートだと移動時間が全くなくなるので拘束時間は実際の勉強会と懇親会の時間だけになり、もし急遽業務の割り込みが合ったとしてもすぐに業務に戻ることも可能です。そのため、気軽に参加してもらうことが可能になり双方の参加者を増やすことが出来ます。

初めての発表の人も気が楽

私もはじめはそうでしたが、見ず知らずの人の前で発表をすることは初めての人にとってはハードルが高いものです。しかし、2社間クローズドにすることで半分は自社の人で見知った人も多くいるため、発表になれていない人にとっても発表がしやすいです。また、当日は資料を完全公開にする必要が無いため後で手直しも可能なため、発表慣れしていない人にとっては嬉しいかしれません。

まとめ

今回は、レバレジーズが現在取り組んでいるマイクロサービス化について、すでに運用して1年ほど経つプレイド社の知見を大いに学ぶことが出来ました。プレイド社のハマったポイントや、レバレジーズで現在抱えている問題をどのように解決しようとしているかなど、知りたいと思っていることを学ぶことが出来ました。 私もいろいろな勉強会に参加していますが、通常は入門的な内容が多かったり、今知りたいことと少しずれてたりするので、ここまで密度の高い勉強会はなかなか経験がありませんでした。

これは、クローズドかつリモートという形式を取ったことの効能だと感じました。 また、勉強会の開催の手間も少なく、ハードルも低いため頻繁かつ定期的に開催することも出来ると思います。今後もひとつ一の取組みとしてクローズドかつ、リモート形式での勉強会を継続して開催し、エンジニアの技術力UPの一助にしていきたいと思います。

レバレジーズと勉強会しませんか?

現在レバレジーズでは、マイクロサービス化、TypeScriptの導入、gRPCの採用、DDDやクリーンアーキテクチャの採用、Vue.js/Reactの導入、IaCによるインフラ管理など様々な技術スタックの刷新を行っています。もし、同じような技術を持っていたり、導入を考えている方いたら竹下や的場にご連絡ください!是非、一緒に勉強会を開催しましょう。

お問い合わせはこちらにお願いします。

レバレジーズの4事業を支える基幹システムのPMとは?

はじめに

こんにちは。プロジェクトマネジャーの丸山です。

最近、プロジェクトマネジメントに関する記事をたくさん見かけますが、 「社内システムのプロジェクトマネジメント」のテーマはそこまで出回ってないように思います。

そこで今回はレバレジーズの社内システムのプロジェクトマネジメントがどのように行われているのかを紹介します。

年商150億円を支える基幹システム!

プロジェクトマネジメントについて紹介する前に、対象のシステムについて紹介させて下さい。 営業活動を行う時に利用するシステムを社内の事業部に提供しています。この類を営業支援システム(SFA)と呼び、有名なものではセールスフォース・Hubspotなどがあります。

このシステムは、顧客管理・案件管理・進捗管理・書類管理・金銭管理といった基本的なSFAに付いている機能に加え、 連絡機能(電話・メール・LINEなど)やマーケティング情報の管理機能なども付いているので、営業だけでなく事業の全領域を支援しています。(よって基幹システムと題させて頂きました)

このシステムは、1つで4事業を支えています。看護師さんの紹介事業・派遣事業・介護士さんの紹介事業・派遣事業の4つです。 看護業界と介護業界の中身は異なる部分が多いですし、紹介事業と派遣事業のビジネスモデルも全く違いますが、人材業界という大きな枠組みでシステムを設計したことで、4事業の全領域を管理するシステムを作ることが出来ました。

このシステムが支援する4事業の年商は約150億円です。社員による月間アクセス数は250万PVとなります。

基幹システムの全体像

プロジェクトマネジメントをどのように行っているか??

それでは本題に入ります。 まず、全体の流れを簡単に示すと下記のようになります。

プランニング → 要件定義 → 基本・詳細設計 → 実装・テスト → 導入 → リリース → 効果測定 → プランニング

このサイクルをおよそ1ヶ月スパンで回しています。関係者が全員社内にいるため、コミュニケーション調整コストがほとんどかからない社内システムだからこそ、この短いスパンを実現出来ています。

それでは、各プロセスについて具体的に説明します。

プランニング

今後どのようなシステム改修をしていくか、4事業の責任者と話し合い、優先順位を付けます。

4事業の売上を最大化するために、事業部の全活動の中でどこを改善するべきか(事業課題)を話し合います。事業課題の中でシステム改修によって解決可能なものがあれば、「このような改修によってこの事業課題がこのくらいのインパクトで改善される」などの提案をします。採用された提案には優先順位を付け、優先度の高い改修のスケジュール調整を行います。

採用された提案の多くがプロジェクトマネジャー発案であり、エンジニアが考えてプロジェクトマネジャーに提供した提案も採用されています。 ほとんどの改修で、自分達がやる価値があると思ったことに取り組んでいるので、開発チーム全体のモチベーションが高く保てています。

要件定義

プランニングで決まったシステム改修案の要件を定義します。

関係者を集め、どのような改修をしようとしているか説明を行い、実現可能性・課題解決の方法・役割分担などについて話し合います。

営業関連の改修なら営業部のリーダー、マーケティング関連の改修ならマーケティング部の担当者と、集める関係者は改修案によって異なります。同じ会社の仲間なので、協力的に動いて下さる社員が多く、様々な領域のプロフェッショナルと意見交換が出来るので視野が広がります。

基本・詳細設計

要件をベースに基本・詳細設計をします。

基本・詳細設計は画面設計・入出力設計・機能設計・DB設計の4つで構成されています。画面設計は、関係者に要望をヒアリングしたりプロトタイプを見せたり意見交換をしながら行います。

要望をヒアリングした際に、4事業の文化の違いやチーム体制の違いにより意見が割れることも多いです。この場合には全ての要望を汲み取るべきか取捨選択するかを意思決定し、対立意見の関係者を説得することで意見を収束させます。

4事業を支えるシステムだからこそ、難しい部分もありますが、この苦労があるからこそ4事業を支えるシステムが成立します。

また、DB設計では4事業間の整合性を担保するために抽象化・正規化が適切に行われているかを細かくチェックします。最初は上手く設計が出来ませんでしたが、経験を経て上手く設計出来るようになりました。DB設計が上手くなっただけでなく、概念的に考える力がかなり成長したと感じます。

実装・テスト

実装・テストの実行はエンジニアが担当します。 プロジェクトマネジャーの担当はスケジューリングとサポートです。

開発チームでは2週間ごとに区切りをつける、スプリント形式で開発しています。スケジューリングとは、プランニングで定めた改修のスケジュールを守れるようにタスクをスプリントに割り振っていくことです。サポートとは、エンジニアに要件や基本・詳細設計を説明をしたり、開発で発生した問題を解決したり、実装・テストを進めるための支援活動のことを指します。

複数のプロジェクトが並行して走ることが多く、スケジューリングやリソース調整は大変になりますが、「スケジュール通りに開発を進める」という、プロジェクトマネジメントで一番基本となる能力の成長に繋がりました。

導入

全てのシステム利用者に改修内容を説明します。

要件定義や基本・詳細設計において全てのシステム利用者を巻き込むことは時間制約上不可能なので、改修に関する情報があまり伝わってないシステム利用者もいます。

その方々に向けて、「どのような事業課題に対してどのような改修をしたのか」を説明します。システムの最大の目的は「4事業の売上の最大化」であるため、それに基づいてシステム利用者が表面上不便に思う改修を行うこともあります。 そのような場合にも改修の理由を事前に説明して理解してもらうことで、システム利用者との信頼関係を保つことが出来ます。

システムへの関心が薄い相手もいる中で、まんべんなく理解を得ることは難しく、このプロセスも初めは苦戦しました。ただ、試行錯誤を重ねる内に分かりやすい説明と共感性の高いメッセージの発信が身について、システム改修について大部分の人に理解してもらえるようになりました。

リリース

エンジニアがリリース作業を行っている間にプロジェクトマネジャーがSlackでリリースを告知します。改修の内容が良ければ、告知後すぐにSlackのスタンプや称賛のコメントがたくさん返ってきます。反応が薄いときはもっと頑張らないとなと思いますし、反応があったときには純粋に嬉しい気持ちになります。

効果測定

システムのアクセスログや売上データを分析して、事業課題の改善インパクトを測定します。インパクトがプランニングで提案した時の基準に達していれば、システム改修によって事業課題を解決出来たと評価されます。

事業課題を解決出来た場合の達成感は、リリース時以上のものです。 リリース時の反応は重要であるものの指標の1つでしかなく、チームの最終的な使命は「事業課題の解決」と、それによる「4事業の売上の最大化」だからです。

事業課題を解決出来た際は、チームSlackで成果を共有し、達成の喜びを分かち合います。数多くの困難を共に潜り抜けたチームメンバーと共に達成の喜びを分かち合えたときは、全ての苦労が報われたように感じます。

効果測定が完了すると、その結果を踏まえて更なる事業課題が無いかプランニングで検討します。効果測定からプランニングに繋がることで、プロセスが循環しています。

まとめ

レバレジーズの基幹システムのPMとは、一言で表すと「4事業の売上最大化を目的としたシステム改修の仕掛け人」です。この仕事には主に2つの魅力があります。

システムの規模が大きいこと

扱っているシステムは基本的なSFAの枠組みを大きく超え、連絡機能(電話・メール・LINEなど)やマーケティング情報の管理機能などもついているので、事業の全てを支える基幹システムと言えます。そのうえ、ひとつのシステムで4事業、年商約150億円を支えています。

裁量権が大きいこと

システム開発において、上流として位置付けられる要件定義だけでなく、事業の責任者と事業課題を話し合うプランニングから参加しているため、システム開発に関する全ての意思決定について関わることが出来ます。

事業を成長させたい思いで自ら考え動かした改修案が、リリースされシステム利用者に喜ばれる、嬉しい経験も味わうことが出来ました。

4事業が関わるシステムなので、難しい意思決定が多いですが、コミュニケーション力や論理的思考力などのスキルの成長に繋がりました。

We are Hiring

レバレジーズではプロジェクトマネジャー・エンジニアを募集しています。 興味を持って頂けた方はこちらからご連絡頂けると幸いです。

https://recruit.jobcan.jp/leverages/

社内電話アプリをChrome拡張機能からElectronにリプレースした話

はじめに

こんにちは!エンジニアの呉です! 今回は社内で開発している電話アプリについて、Chrome拡張機能からElectronへリプレースした話をご紹介します。

リプレースしたきっかけ

■問題点

社内で開発している電話アプリでは、いくつかの問題が顕在化していました。

  • コードの見通し
    • 電話という特異的な機能に加えて、Chrome拡張機能独自のお作法によりコードの見通しが悪くなっていた
  • 手動リリース
    • ウェブストアのダッシュボードから審査の申請をする必要がある
  • リリースコントロールがしにくい
    • Chrome拡張機能の自動更新が最大5hのタイムラグが生じる(chroniumの対象コード)
    • ウェブストアの審査が介入するため、リリースが手間

■解決手段

今回これらの問題を解決する手段として、Electron + Vue.jsでリプレースをすることを決めました。

  • コードの見通し
    • 社内で知見の多いVue.jsを採用し、学習コストを低減
    • 言語はTypeScriptを採用し、型宣言による開発効率、保守性の向上
    • 表示のコンポーネント化、機能のモジュール化を行うことにより、それぞれの責務を明確にし、コード全体の見通しを向上
  • 手動リリース
    • GitHub Actionsを利用し、リリースを自動化
  • リリースコントロールがしにくい
    • electron-builderのelectron-updaterパッケージを使い、自動更新タイミングを自分でコントロールできるように解決
    • AWS S3へのアップロードを行うだけのため、審査介入によるリリースコストダウン

出来上がったもの

今回出来上がったものを簡単なご紹介します。

■Electronアプリケーション

主なディレクトリ構成は以下の通りです。

src
 ├── assets
 ├── background
 ├── components
 ├── constants
 ├── models
 ├── plugins
 ├── router
 ├── services
 ├── store
 └── views
ディレクトリ名 役割
assets グローバルで利用するCSSやフォント、ロゴなどのリソース
background Electronアプリケーションのライフサイクル制御とプロトコル設定、バージョンチェックなど
components 表示部品単位でのコンポーネント定義
constants 通話で利用する結果コードの定義や外部イベントの定数を定義
models APIや通話で利用する連絡先などのモデル定義と型定義
plugins API通信で利用するaxiosやGoogle OAuth、Slackなど外部ライブラリのラッパーを定義
router ページのルーティング定義
services 業務ロジックをサービス層として抽出し定義
store モジュール単位での状態管理とアクション定義
views ページ単位単位でのコンポーネント定義

■リリースフロー

リリースフロー

これまで他のプロジェクトでは、CircleCIを使ったリリースの自動化をしていましたが、今回はGitHub Actionsを使ったリリース方法を採用しました。

理由としては、

  1. 社内でElectronを使ったプロジェクトが多く発足する可能性を考慮し、プロジェクト独自のリリースフローで良いと判断
  2. GitHub Actionsのワークフローを定義のみで設定作業のコストを削減
  3. CircleCIのmacOSビルド環境(executor)を追加で契約する必要があったため、ランニングコストを削減

よかった点

今回電話アプリの開発を実際に担当している身として、前述の問題点に対してどうすべきなのか、どうしたらやりやすくなるのかをエンジニアサイドから考えた上での行動に起こしました。

■運用保守コスト

結果として、リプレースによる利用者の満足度を向上させるようなダイレクトなインパクトはありませんでしたが、エンジニアサイドの心理的安心感や運用保守コストダウンにより、間接的に利用者に機能提供するまでの開発効率を向上することができました。

■スキル

ElectronやGitHub Actionsなど社内でもあまり導入実績のない技術に対して挑戦することで、個人の成長を実感することができました!

■意思決定

今回のリプレースの提案に対しても「イイじゃん!イイじゃん!」と共感してもらった上で、その場で「じゃあいつまでにできそう?」とスピード感に若干驚きました(笑)

ボトムアップの提案に対してもスピーディに対応し、承認までの間隔が短く、提案することに対して億劫にならない環境だなーと私個人としてとても印象に残りました。

大変だった点

…ここまで良いことばかり書いてきましたが、もちろん良いことだけではありませんでした。

■Twilio

元々前任者がベースを開発していたこともあり、全容を完璧に把握できていたわけではなかったので、動作が変わらないように全体を見渡す時間がとてつもなくかかってしまいました。

■Appleの公証

macOSを利用している方もいるため、Appleの公証(アプリ署名)を行う必要がありました。

Apple Developer Programからの証明書発行、発行した証明書を用いてビルド・リリースの自動化で苦戦をしました。

最終的にはelectron-notarizeを使うことで解決しました。

まとめ

いかがでしたでしょうか?

社内のカイゼン事例として、社内電話アプリのChrome拡張機能からElectronにリプレースした話をご紹介いたしました。

今回エンジニアによるボトムアップからの提案に対してスピーディに実現ができたことが素直にとても嬉しかったです。

みなさんも「やりたい!」と思ったことをまずは声に出してみるところから始めてみてはいかがでしょうか。

We are Hiring

レバレジーズでは、一緒に今をより良くしていく仲間を募集しています。

弊社に少しでも興味を持っていただいた方は是非ご連絡いただけると幸いです。

https://recruit.jobcan.jp/leverages/

ネイティブアプリをFlutterにフルリプレイスした話

はじめに

こんにちは!エンジニアの藤野です! 今回はキャリアチケットが運営するキャリアチケットカフェのiOS/Android向けアプリをFlutterにフルリプレイスした話をご紹介します。

なぜFlutterに移行したのか

元々アプリ開発はiOSはSwift, AndroidはKotlinを使用し、開発を進めていましたが、開発を進めていく上で生産性が上がらない問題が発生していました。

最終的には1人でモバイル開発をしていたため、iOS/Androidのどちらとも実装する時間がなく、iOSのみに実装するといったOSによって機能が違うアプリになっていました。また、1人で仕様検討から実装・リリースまでを行っていたため、開発効率も下がっていました。レビュワー等もいなかったため、プルリクエストを投げた後もセルフマージで対応していました。

以上のような問題を解消するために、Flutterにフルリプレイスすることを決めました。Flutterにした理由は下記2つです。

  • 未経験でも取っ付き易い
  • OS関係なくひとつの言語で統一できる

他のチームメンバーにもFlutterを触ってもらうことで、開発できる人を増やせること、さらに生産性を向上させるメリットがあったため、Flutterへのフルリプレイスへ踏み切りました。

構成

今回使用した状態管理手法やディレクトリ構成を紹介します。 状態管理の手法はChangeNotifier+Providerパターンを採用しています。ProviderはPragmatic State Management in Flutter (Google I/O'19)で公式に推奨されています。 設計当初にはまだ登場していませんでしたが、最近はStateNotifier+Freezed+Providerを使った状態管理やRiverpodの登場などがあります。

ディレクトリ構成

ディレクトリ構成は以下の通りです。どのような役割になっているかも、ひとつずつ簡単に説明しています。

lib
 ├── config
 │    └─ route.dart
 ├── models
 │    └─ tmp
 │      ├─ tmp.dart
 │      ├─ tmp.freezed.dart
 │      └─ tmp.g.dart
 ├── resources
 │    ├─ api
 │    │   └─ tmp_api_provider.dart
 │    └─ repositories
 │      └─ tmp_repository.dart
 ├── screens
 │    ├─ common
 │    └─ tmp
 │      ├─ widgets
 │      └─ tmp_screen.dart
 ├── services
 ├── utils
 ├── assets
 ├── viewmodels
 │    ├─ common
 │    └─ tmp
 │      └─ tmp_view_model.dart
 └── main.dart
  • config
    • 設定ファイルを配置(ルーティングを設定するファイルなど)
  • models
    • 作成するモデルのディレクトリを作ってその中にdartファイルを配置する
    • freezedパッケージを使用
  • resources
    • api
      • APIとのやり取りをするファイルを配置
      • ファイル名 : xxxx_api_provider.dart
    • repositories
      • apiからデータを取得して各モデルの形にマッピングしたり、データ送ったりする
      • ファイル名 : xxxx_repository.dart
  • screens
    • 各画面ごとにディレクトリを作成してその中にファイルとwidgetsディレクトリを作成する
    • 基本的には1画面1screenを作成し、widgets配下に画面内で使用するWidgetを配置
  • services
    • 主に外部サービスとの連携周りの処理を書いたものを配置
  • utils
    • 定数などのファイルを配置
  • assets
    • 画像などの素材系を配置
  • viewmodels

構成は以上のようになっています。伝わりづらい部分はサンプルとして、 APIからデータを取得して表示するだけの簡単なアプリを作成しながら詳しく説明します。

1. パッケージの導入

providerパッケージやfreezedパッケージを利用するため、pubspec.yamlを以下のようにします。

dependencies:
  flutter:
    sdk: flutter
- cupertino_icons: ^0.1.3
+ provider:
+ freezed_annotation:
+ http:

dev_dependencies:
  flutter_test:
    sdk: flutter
+ json_serializable:
+ build_runner:
+ freezed:

2. モデルの作成

モデルはFreezedパッケージを使用しているため、以下のように書いた後に以下のコマンドを実行します。 flutter pub run build_runner build これによって event.freezed.dart ファイルと event.g.dart が生成されます。 詳しいFreezedパッケージの使い方はドキュメントを参照してください。

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';

part 'event.freezed.dart';
part 'event.g.dart';

@freezed abstract class Event implements _$Event {
  factory Event({
    int id,
    String title,
    @nullable @JsonKey(name: 'created_at') String createdAt,
  }) = _Event;

  factory Event.fromJson(Map<String, dynamic> json) => _$EventFromJson(json);
}

3. APIからの取得処理実装

APIから実際にデータを取得する部分をプロバイダーとして作成します。

import 'package:http/http.dart' as http;

class EventApiProvider {
  Future getAll() async {
    return await http.get('http://localhost:8080/events');
  }
}

Repositoryでは上記で作成したプロバイダーから取得したデータをモデルの形にマッピング

import 'dart:convert';
import 'package:sample/models/event.dart';
import 'package:sample/resources/api/event_api_provider.dart';

class EventRepository {
  Future<List<Event>> getAll() async {
    final _response = await EventApiProvider().getAll();
    return json.decode(_response.body)
        .map<Event>((json) => Event.fromJson(json))
        .toList();
  }
}

4. ViewModelの作成

ここで先ほど作成したEventRepositoryを利用してデータを取得し、画面側へ結果を伝えます。

import 'package:flutter/material.dart';
import 'package:sample/models/event.dart';
import 'package:sample/resources/repositories/event_repository.dart';

class EventViewModel extends ChangeNotifier {
  final EventRepository _repository = EventRepository();
  List<Event> events = [];

  Future getAll() async {
    events =  await _repository.getAll();
    notifyListeners();
  }
}

5. Screenの作成

ボタンを押すとAPIからデータを取得し、取得結果を表示するような画面を作成します。 ボタン押下時にViewModel側の getAll() を呼び出してデータを取得、notifyListeners() が呼び出されたタイミングでListViewが再描画されます。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sample/viewmodels/event_view_models.dart';

class EventScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    EventViewModel _viewModel = Provider.of<EventViewModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Event List'),
      ),
      body: Center(
        child: Column(
          children: <Widget>[
            FlatButton(
              onPressed:() async => _viewModel.getAll(),
              color: Colors.lightBlue,
              child: const Text('取得'),
            ),
            Expanded(
              child: ListView.builder(
                itemCount: _viewModel.events.length,
                itemBuilder: (BuildContext context, int index) {
                  return ListTile(
                    title: Text(
                        "${_viewModel.events[index].id} : ${_viewModel.events[index].title}"
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

6. main.dartの修正

各画面の遷移先は以下のようにルーティングファイルに記載します。

import 'package:flutter/material.dart';
import 'package:sample/screens/event_screen.dart';

final Map<String, WidgetBuilder> routes = {
  '/': (BuildContext context) => EventScreen()
};

そして MaterialAppのroutesに先ほど作成したルーティングを設定することによってEventScreenが呼び出されるようになります。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sample/config/route.dart';
import 'package:sample/viewmodels/event_view_models.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      child: MaterialApp(
        routes: routes,
      ),
      providers: [
        ChangeNotifierProvider(create: (context) => EventViewModel()),
      ],
    );
  }
}

完成!

取得ボタンを押すと、APIからデータを取得し表示します。

実際にリプレイスしてみて

メリット

  1. 開発工数の削減 Flutterで開発すると、SwiftとKotlinの別々で書いていたときよりも 体感4割ほど工数が削減できました。ただ、全て共通で同じ実装をできるかと言われるとそうでなく、OSに依存する機能などは個別に実装する必要があるため、その部分に関しては工数がかかってしまいます。 また、Flutterの大きな特徴の一つであるホットリロード機能がとても便利で、開発中のデバッグにかかる時間がかなり削減されました。

  2. 未経験でも開発が容易 今回のリプレイス作業は、自分以外のチームメンバーのほとんどがFlutter未経験者でしたが、全員スムーズに開発をすることができました。公式ドキュメントが充実していて、UI部分に関してもSwiftやKotlinよりも簡単に実装ができるため、Flutter未経験でも簡単にモバイルアプリを開発することができます。

  3. UIの実装が簡単 こちらもFlutterの大きな特徴ですが、UI部分は全てWidgetで構成されており、マテリアルデザインなどといった、様々なデザインが備わっています。そのため、ある程度のデザインであれば公式のライブラリを使用することで、簡単に実装をすることが可能です。

  4. コードレビューが楽 SwfitやAndroidではそれぞれStoryBoardとレイアウトファイル(xmlファイル)で実装していたため、コードレビューの際に変更箇所の差分が分かりにくく、苦戦していました。Flutterに移行したことで、変更箇所が分かりやすく、コードレビューが楽になりました。

デメリット

  1. OSに依存する機能などは個別に実装が必要 OS依存の機能を使わない場合に関しては、特に問題ありません。通知機能やAppleサインインなどのOS依存の機能を実装する場合は、その部分に関する知識が必要になってきます。 また、リリース時もiOSとAndroidで証明書が異なるため、別々に管理することが必要となります。

  2. パフォーマンスを考慮した設計 さまざまなWidgetを組み合わせて簡単にUI部分を実装することは可能ですが、何も気にせずに実装を進めていくと、ネイティブよりもパフォーマンスが低下し、アプリ実行時に全体的にもっさりした感じになります。そのため、複雑なUIなどの場合にはパフォーマンスを考慮して設計をしなければいけません。

開発を行ったチームメンバーの感想

モバイルアプリ開発未経験にも関わらず、リプレイス作業に携わってもらったチームメンバーから、以下のような感想を貰いました。

  • 環境構築がそこまで複雑ではないのですぐに開発をすることができた
  • UI部分はWidgetがHTMLみたいで直感的で書きやすい
  • UIパーツを組み上げる感覚で、思ったほど苦労せずに実装できた
  • Flutterの進歩が早い
  • 開発がスムーズ進められた(デバッグツールが使いやすい、ホットリロード機能最強!など)

メリットでもあるUI部分に関しては、直感的で未経験者でも開発がしやすいとのことです。

Flutter移行中に起きた問題とその対応

Flutterに移行する際にさまざまな問題等に直面したので、その内容と解決方法、注意点などをいくつかご紹介します。

UI設計をあらかじめした方が良い

今回は画面単位で担当者を割り振ったため、コンポーネント化出来るWidgetがコンポーネント化されていなかったりと煩雑になってしまいました。他にもContainer Widgetを使う実装があったので、パフォーマンスなども考慮して適切なWidgetを使うことをおすすめします。

iOSのバージョンアップデートには事前に対応しておくべき

今回iOS側にはUniversal Links機能を実装していたのですが、アプリのリリース前にiOSのバージョンがiOS 14に上がったことでAssociated Domains周りの仕様が若干変更されました。そのため、サーバサイド側の設定変更を余儀なくされました。 iOSの次バージョンがリリースされる3ヶ月ほど前の6,7月頃にはBeta版がリリースされるので、事前にBeta版を使ってあらかじめ対応するべきでした。

審査に落ちてしまった

iOS側の話が続きますが、いざリリース申請に出したところ、App Store Reviewガイドラインの5.1.1(xi)に引っ掛かっている理由のため、リジェクトにされました。 ガイドラインに引っ掛かった原因は、アプリ内に下記のようなRSSから記事を取得して表示する機能を実装していたものの、その中に「新型コロナウイルス」に関する記事が表示されていたため、リジェクトされていました。

そのため、サーバサイド側でRSSから記事を取得する際、タイトルに「新型コロナウイルス」に関する単語が入っている場合は表示しないよう、一時的に対応しました。その後、もう一度審査に出したところ、何の問題もなく審査を通過しました。 外部からの取得したデータを表示する場合などは、念のため気をつけましょう。

リリース周りが大変

iOSとAndroidではデメリットのひとつ目にも記載していますが、リリースまでの手順が異なるため、OSごとに対応が必要となってきます。ビルドからデプロイまで全て手動で行うのは大変でしたが、現在ではFlutterなどモバイルに特化したCI/CDツールであるCodemagicの導入を検討しています。

終わりに

今回、SwiftとKotlinで実装されたアプリをFlutterに移行した話をご紹介しました。チームメンバーのほとんどがFlutter未経験にもかかわらず、スムーズに移行を進めることができました。

この移行プロジェクトは自分からチームリーダーに提案をしたものですが、快く承諾していただきました。メンバーが提案しやすい環境があること、感謝しています。

未経験者でも、簡単にモバイルアプリ開発をすることができます。Flutterを触ったことのない方は是非、Flutterを触ってモバイルアプリ開発をしてみてください。