dodaアプリを支える技術とアーキテクチャ・設計思想

dodaアプリを支える技術とアーキテクチャ・設計思想

doda アプリ開発グループの坂戸です。

今回は doda アプリがどのような技術を使用してアーキテクチャ・設計しているかをお話しします。 前半部分をフロントエンド、後半部分をバックエンドに分けて説明していきます。

フロントエンド

doda アプリのフロントエンドはざっくり以下の構成で成り立っています。

  • ReactNative
  • ReactNativeFirebase
  • typescript
  • ReduxToolkit
  • Realm
  • jest

今回は設計のお話をしたいので、各ライブラリの詳細な説明などは割愛させていただきます。 まずは外観をご覧ください。

dodaアプリフロント概要

Redux

フロントエンドで取り扱うデータを格納する層です。 各ドメインの粒度でSliceを切ってデータを管理しております。 ディレクトリ構成は Re-ducks パターンを採用。基本的にはReduxの原則に則って管理しています。

ScreenReduxを繋ぐ中間層については後述します。

Realm

主にユーザー操作による状態維持とマスタデータの保持を目的にRealmを導入しています。数としてはマスタデータが圧倒的に多く、テーブル定義も BE の定義に倣っているため、Realm自体には特筆して記載することはありません。

Realmにマスタデータを持たせている理由は、パフォーマンスの向上です。マスタを都度 API から取得すると、どうしてもネットワークを経由する分パフォーマンスが落ちてしまいます。マスタ以外の API 通信がない画面もあるため、アプリのローカルに持たせる設計としました。Realmで持つマスタデータをどう最新化するかは、バックエンドの説明で記載します。

Reduxと同様にScreenとの中間層については後述します。

Redux, Realm ~ Screen

Screenと各データを繋ぐやりとりの層がこちらです。 データの取り回しの時に特に意識したのは Single Source of Truth の考え方をチーム内で徹底することでした。 Realmが入ってくるとどうしても Store のような状態を保持する受け皿が複数存在するような見え方になってしまいます。 常にReduxのデータを参照するために、Realmは必ずReduxのミドルウェアを経由してloadwriteを行います。 createAsyncThunkを使い API やRealmへのアプローチをすることで、データを永続化しつつReduxにもデータを格納する設計にしてReduxの設計思想から外れないよう心がけています。

格納されたデータはSelectorを利用して画面側へ渡します。 データをそのまま受け取らず、加工した上で渡したい時はcreateSelectorなどを用いてSelectorを拡張したりもします。

Screen

スクリーンの層では大きくContainerPresentationに分割し、Container側でデータハンドリング、Presentation側に描画を担当させることで責務の分散を図っています。

Container層では前述したcreateAsyncThunkを用いて作成された関数のdispatchや、Selectorでデータを取得するのがのメインの役割となっています。Presentation層で使用するイベントハンドラの作成もここで行っています。

Containerで作成するのは主にイベントが実行する処理と計測処理を分離して管理し、Containerでマージを行うためです。例えばとあるボタンを押下した時のイベントに対して、計測用の関数と押下時処理の関数を別で用意してContainer層でマージして、イベントハンドラとしてPresentationに流していきます。

こうすることで計測処理とイベントの処理を切り離して考えることができます。

ここまでを踏まえたContainerのサンプルコードは以下です。

export const ExampleContainer = ({ navigation }: Props) => {
  const { data } = useFetchExampleContainer();
  const { exampleHandler } = useCreateHandler(navigation);
  return <ExamplePresentation data={data} exampleHandler={exampleHandler} />;
};

Containerは後述するReactNavigationで import されるので、Props として遷移の情報が入ってきます。

useFetchExampleContainerでは内部でuseDispatchuseSelectorを実行し取得したデータを返却する CustomHooks です。

useCreateHandlerではハンドラの作成します。多くの画面ではなにかしらのインタラクションに対応して遷移が発生するので、ここに引数でnavigationを渡してあげることが多いです。

全てPresentationに渡してあげて、それらのデータは各コンポーネントに流していきます。

Presentationでは描画に必要なコンポーネントを呼び出し、Containerからきた Props の振り分けを主に行う描画のトップレベルコンポーネントになります。

export const ExamplePresentation = ({ data, exampleHandler }: Props) => {
  return (
    <>
      <ExampleComponent data={data} />
      <Button onPress={exampleHandler}>サンプルボタン</Button>
    </>
  );
};

この設計ではそれぞれの責務がはっきりし、スクリーンに使用しているデータやハンドラがトップレベルに存在することが約束されている、というメリットの一方で、デメリットも存在します。Props のバケツリレーが多くなることや hooks のネストが深くなりやすいことなどが挙げられますが、これらをどう緩和していくかが今後の課題となっています。

作成したScreenを import して画面遷移を表現するのがReactNavigationです。 画面遷移と遷移時のアニメーションまでサポートしてくれます。

Navigation 周りの実装については正直シンプルな実装にしきれておらず、課題感を多く抱えています。 ReactNavigationがとても優秀で多機能なため、ここに多くの責務を寄せすぎたことでかなり膨らんだ箇所になっています。 基本的なことではありますが、なんでもできるからと言って一箇所に責務を寄せすぎるのは良くないですね。

大きい反省点の 1 つをお話します。Navigation のネストのお話です。

    <Navigator initialRouteName={'hoge'}>
      <Screen name={'hoge'} component={HogeContainer} />
      <Screen name={'fuga'} component={FugaNavigator} />
      <Screen name={'piyo'} component={PiyoContainer} />
    </Navigator>

Screenで作成したContainer<Screen>で呼び出します。この時fugaのようなイメージで<Navigator>を入れ子にできます。 この建て付けで<Navigator>を入れ子にしていくと、入れ子の先にあるScreenは兄弟のScreenにしか遷移できなくなるため親へ戻るような記述にしなければならず、直感でわかりづらくなります。

コードの視認性の観点からネストしていたのですが当時はデメリットをわかっておらず、今になるとこの作りにすべきではなかったと考えます。

個人的に特に画面数が多いアプリケーション作成の際には、ネストは利用せず root の<Navigator>へフラットに並べる方が良さそうだなという印象です。

ReactNavigationVersion:6.xからはGroupという考え方が追加され、フラットに並べても画面のカテゴライズがしやすくなったようです。私たちもこれらの機能を利用してより良い遷移のハンドリングを目指していきます。

テストについて

基本的に処理はコンポーネント内部などに書かず関数として切り出すことを原則とし、全ての関数でユニットテストをする方針で開発を進めています。 テストフレームワークとしてはjestを採用しました。

テストについて全体的にテスタブルな設計になるよう、関数や hooks などの責務分けを大切にしています。 ロジック部分をきちんとテストすることで大きいバグは少なく抑えることができた一方で、結合テストや E2E をしてないのでリファクタ時のリグレッションなどでバグが出やすく課題となっています。

そのため、開発初期段階では費用対効果の観点で避けていた結合テストや E2E の導入を現在進めています。 結合テストにはReactNativeTestingLiblaryを使用し、 E2E にはDetoxを採用予定です。

バックエンド

doda アプリのバックエンドは、以下の構成で成り立っています。

  • インフラ (AWS)
    • Route53
    • ELB
    • EC2
    • Aurora (PostgreSQL)
    • ElastiCache (Redis)
    • S3
    • CloudFront
    • Lambda
  • API (SpringBoot)
  • Batch (SpringBatch)
  • その他
    • Akamai WAF
    • Firebase
    • doda サイト用 API
    • doda サイト用 DB (Oracle)
    • Solr

外観は以下のようになっています。

dodaアプリサーバ構成

ElastiCache (Redis)

DB アクセスを削減し API のパフォーマンスを上げる目的で、マスタデータを ElastiCache でキャッシュさせています。 doda はサイトとアプリで同じマスタデータを使用しており、サイトで更新があった場合はアプリ側の DB やフロントエンドの Realm にも更新内容を反映させる必要があります。

  • doda サイトのマスタデータを Batch で日次取得し、doda アプリのAuroraと差分を比較する
  • 差分があれば、AuroraのデータとRedisのキャッシュを更新する
  • doda アプリ起動時にマスタデータ取得 API を呼び出し、マスタの更新があればフロントエンドのRealmに反映する

S3, CloudFront, Lambda

S3 ではアプリ上で表示するお知らせのデータを管理しています。

doda アプリ公開当時、アプリ上へのお知らせ掲載は以下の手順で行っていました。

  1. ディレクターがお知らせの文言を考え、エンジニアに連携する
  2. エンジニアはお知らせデータがある DB のデータを更新し、さらにRedisのキャッシュをクリアする
  3. doda アプリは API を呼び出してお知らせの内容を取得し表示する

1, 2 の作業を手動で行っており、1 回あたりの作業工数は微々たるものですが、お知らせの更新は頻繁に発生するため、工数改善が課題になっていました。

そこで設計を見直し、現在は以下のような仕組みになっています。

  1. お知らせの内容を json ファイルに記載しS3へ配置、CloudFrontでキャッシュする
  2. お知らせを更新したい場合は、ディレクターが直接S3上の json ファイルを編集する
  3. S3のファイル更新を検知し、LambdaからCloudFrontのキャッシュをクリアする

やっていることはシンプルですが、工数削減には大きく寄与しました。

API (SpringBoot)

API はSpringBootをフレームワークとして採用しており、アーキテクチャ構成は以下の通りです。

dodaアプリサーバアーキテクチャ概要

ここからはサンプルコードを交えてお話しします。

Authorization

API にRequestが来た時、Interceptorで正しいアクセス元からのRequestかを判定しています。

Authentication

アプリを利用するユーザーがログインしている/していないの判定を行なっています。

ログイン状態では doda アプリのデータは doda サイトと同期を取っており、ログイン時/未ログイン時で処理に分岐が発生します。 そのため、 API 処理の頭でログイン状態を判定しています。

Controller

Controllerは、以下の役割として定義しています。

  • Requestを受け取り、パラメータ(Model)をServiceへ渡す
  • Serviceから返ってきたデータ(Model)をResponseに詰める
  /**
   * 会員情報を取得する.
   * 
   * @param request 会員情報リクエスト
   * @return 会員情報レスポンス
   */
  public MemberInfoResponse get(MemberInfoGetRequest request) {

    // 会員情報取得
    List<MemberInfoModel> modelList = this.service.get(request.getMemberId());

    MemberInfoResponse response = MemberInfoResponse.builder().memberInfoList(modelList).build();

    return response;
  }

Requestに対する Validation と Validation message は、Model内で設定しています。

/** 会員情報モデル. */
public class MemberInfoModel {

  /** 会員ID. */
  @Digits(integer = 10, fraction = 0)
  private long memberId;

  /** 姓. */
  @NotBlank
  @Size(max = 25, message = "{lastName}{javax.validation.constraints.Size2.message}")
  private String lastName;

  /** 名. */
  @NotBlank
  @Size(max = 25, message = "{firstName}{javax.validation.constraints.Size2.message}")
  private String firstName;
}

Service

Serviceは、ビジネスロジックを記述する場所として定義しています。 Serviceからデータ層へのアクセスは、Repositoryを使用します。

ServiceからRepositoryへは、パラメータとしてEntityを渡すようにしています。 ModelをそのままRepositoryに渡しても成立するケースもありますが、密結合になってしまうため、Entityに詰め替えることで、疎結合化を実現しています。

  /**
   * 会員情報取得.
   * 
   * @param memberId 会員ID
   * @return 会員情報
   */
  public List<MemberInfoModel> get(Long memberId) {

    // DBから会員情報取得
    List<MemberInfoEntity> entityList = this.repository.get(memberId);
    if (CollectionUtils.isEmpty(entityList)) {
      // DBからデータを取得できなかった場合
      return Collections.emptyList();
    }

    // 返却用リストに取得データをコピー
    List<MemberInfoModel> modelList = new ArrayList<>();
    entityList.forEach(entity -> {
      MemberInfoModel model = new MemberInfoModel();
      BeanUtils.copyProperties(entity, model);
      modelList.add(model);
    });

    return modelList;
  }

Repository

Repositoryは、データアクセスするための場所として定義しています。

RepositoryからMapperRestTemplateを使用して、DB や外部サービスなどにあるデータにアクセスします。

  /**
   * 会員情報取得.
   * 
   * @param memberId 会員ID
   * @return 会員情報エンティティリスト
   */
  public List<MemberInfoEntity> get(Long memberId) {
    return this.mapper.get(memberId);
  }

MyBatis (Mapper & SQL XML)

doda アプリでは、DB へのアクセスはMyBatisを採用しています。

以下のようなパターンで簡単に実装できるので、MyBatisはありがたいなぁと感じます。

  • あるパターンで SQL に条件を 1 つ追加したい (似た SQL の統一)
  • INSERT 時にまとめてデータを渡したい(DB Open/Close 回数の削減)
  • etc
  /**
   * 会員情報取得.
   * 
   * @param memberId 会員ID
   * @return 会員情報エンティティリスト
   */
  List<MemberInfoEntity> get(@Param("memberId") Long memberId);
    <select id="get" resultMap="MemberInfoResultMap">
        SELECT
            member_info.id
            ,member_info.last_name
            ,member_info.first_name
            ,member_info.mail_address
            ,member_info.address
            ,member_info.master_career_id AS career_Id
            ,master_career.name AS career_name
            ,member_info.career_year
        FROM
            member_info
            INNER JOIN master_career
            ON member_info.master_career_id = master_career.id
        <if test="memberId != null">
        WHERE
            member_info.id = #{memberId}
        </if>
        ORDER BY
            member_info.id
        ;
    </select>

RestTemplate

RestTemplateは、外部のサービスへ HTTP アクセスするために利用しています。 doda アプリでは、主に doda サイトへアクセスする時に使用します。

Batch (SpringBatch)

doda アプリでは、Batch のフレームワークにSpringBatchを採用しています。 SpringBootで培ったナレッジを活かせるという点も、採用した一因になっています。

doda アプリの Batch は、主にアプリへの Push 送信処理です。 例えば、「企業からスカウトが届きました」といった連絡をスマートフォンに通知しています。

Push 通知の送信にはFirebase Cloud Messagingを利用しています。

あとがき

今回は doda アプリのアーキテクチャや技術スタックについてご紹介させていただきました。 アプリ開発に興味のある方、フロント-バックエンドの疎結合化を検討されている方のご参考になれば幸いです。

初期段階できっちり設計しているつもりでも、運用の中で新たな課題が見つかるのもシステム開発の常ではあるので、全体を把握しつつリファクタリングや設計の見直しをし続けるのが重要と感じます。 doda アプリで取り組んでいる改善施策についても、また機会があればお話させていただきます。

プロダクト開発統括部 第1開発部 dodaアプリ開発グループ リードエンジニア 坂戸 真悟

坂戸 真悟 Shingo Sakato

プロダクト開発統括部 第1開発部 dodaアプリ開発グループ リードエンジニア

2021年10月に、パーソルプロセス&テクノロジーからパーソルキャリアにグループ内で転籍。 パーソルプロセス&テクノロジー時代から、転職サイトサービス『doda』やその周辺システムの開発・保守に従事。現在はdodaアプリ開発チームのスクラムマスター兼エンジニアとして、チーム運営プロダクトの改善に取り組んでいる。

※2022年1月現在の情報です。