doda アプリ開発グループの坂戸です。
今回は doda アプリがどのような技術を使用してアーキテクチャ・設計しているかをお話しします。 前半部分をフロントエンド、後半部分をバックエンドに分けて説明していきます。
フロントエンド
doda アプリのフロントエンドはざっくり以下の構成で成り立っています。
- ReactNative
- ReactNativeFirebase
- typescript
- ReduxToolkit
- Realm
- jest
今回は設計のお話をしたいので、各ライブラリの詳細な説明などは割愛させていただきます。 まずは外観をご覧ください。
Redux
フロントエンドで取り扱うデータを格納する層です。
各ドメインの粒度でSlice
を切ってデータを管理しております。
ディレクトリ構成は Re-ducks パターンを採用。基本的にはRedux
の原則に則って管理しています。
Screen
とRedux
を繋ぐ中間層については後述します。
Realm
主にユーザー操作による状態維持とマスタデータの保持を目的にRealm
を導入しています。数としてはマスタデータが圧倒的に多く、テーブル定義も BE の定義に倣っているため、Realm
自体には特筆して記載することはありません。
Realm
にマスタデータを持たせている理由は、パフォーマンスの向上です。マスタを都度 API から取得すると、どうしてもネットワークを経由する分パフォーマンスが落ちてしまいます。マスタ以外の API 通信がない画面もあるため、アプリのローカルに持たせる設計としました。Realm
で持つマスタデータをどう最新化するかは、バックエンドの説明で記載します。
Redux
と同様にScreen
との中間層については後述します。
Redux, Realm ~ Screen
Screen
と各データを繋ぐやりとりの層がこちらです。
データの取り回しの時に特に意識したのは Single Source of Truth の考え方をチーム内で徹底することでした。
Realm
が入ってくるとどうしても Store のような状態を保持する受け皿が複数存在するような見え方になってしまいます。
常にRedux
のデータを参照するために、Realm
は必ずRedux
のミドルウェアを経由してload
やwrite
を行います。
createAsyncThunk
を使い API やRealm
へのアプローチをすることで、データを永続化しつつRedux
にもデータを格納する設計にしてRedux
の設計思想から外れないよう心がけています。
格納されたデータはSelector
を利用して画面側へ渡します。
データをそのまま受け取らず、加工した上で渡したい時はcreateSelector
などを用いてSelector
を拡張したりもします。
Screen
スクリーンの層では大きくContainer
とPresentation
に分割し、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
では内部でuseDispatch
やuseSelector
を実行し取得したデータを返却する CustomHooks です。
useCreateHandler
ではハンドラの作成します。多くの画面ではなにかしらのインタラクションに対応して遷移が発生するので、ここに引数でnavigation
を渡してあげることが多いです。
全てPresentation
に渡してあげて、それらのデータは各コンポーネントに流していきます。
Presentation
では描画に必要なコンポーネントを呼び出し、Container
からきた Props の振り分けを主に行う描画のトップレベルコンポーネントになります。
export const ExamplePresentation = ({ data, exampleHandler }: Props) => { return ( <> <ExampleComponent data={data} /> <Button onPress={exampleHandler}>サンプルボタン</Button> </> ); };
この設計ではそれぞれの責務がはっきりし、スクリーンに使用しているデータやハンドラがトップレベルに存在することが約束されている、というメリットの一方で、デメリットも存在します。Props のバケツリレーが多くなることや hooks のネストが深くなりやすいことなどが挙げられますが、これらをどう緩和していくかが今後の課題となっています。
Navigation
作成した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>
へフラットに並べる方が良さそうだなという印象です。
ReactNavigation
のVersion: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
外観は以下のようになっています。
ElastiCache (Redis)
DB アクセスを削減し API のパフォーマンスを上げる目的で、マスタデータを ElastiCache でキャッシュさせています。 doda はサイトとアプリで同じマスタデータを使用しており、サイトで更新があった場合はアプリ側の DB やフロントエンドの Realm にも更新内容を反映させる必要があります。
- doda サイトのマスタデータを Batch で日次取得し、doda アプリの
Aurora
と差分を比較する - 差分があれば、
Aurora
のデータとRedis
のキャッシュを更新する - doda アプリ起動時にマスタデータ取得 API を呼び出し、マスタの更新があればフロントエンドの
Realm
に反映する
S3, CloudFront, Lambda
S3 ではアプリ上で表示するお知らせのデータを管理しています。
doda アプリ公開当時、アプリ上へのお知らせ掲載は以下の手順で行っていました。
- ディレクターがお知らせの文言を考え、エンジニアに連携する
- エンジニアはお知らせデータがある DB のデータを更新し、さらに
Redis
のキャッシュをクリアする - doda アプリは API を呼び出してお知らせの内容を取得し表示する
1, 2 の作業を手動で行っており、1 回あたりの作業工数は微々たるものですが、お知らせの更新は頻繁に発生するため、工数改善が課題になっていました。
そこで設計を見直し、現在は以下のような仕組みになっています。
- お知らせの内容を json ファイルに記載し
S3
へ配置、CloudFront
でキャッシュする - お知らせを更新したい場合は、ディレクターが直接
S3
上の json ファイルを編集する S3
のファイル更新を検知し、Lambda
からCloudFront
のキャッシュをクリアする
やっていることはシンプルですが、工数削減には大きく寄与しました。
API (SpringBoot)
API はSpringBoot
をフレームワークとして採用しており、アーキテクチャ構成は以下の通りです。
ここからはサンプルコードを交えてお話しします。
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
からMapper
やRestTemplate
を使用して、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 アプリで取り組んでいる改善施策についても、また機会があればお話させていただきます。
坂戸 真悟 Shingo Sakato
プロダクト開発統括部 第1開発部 dodaアプリ開発グループ リードエンジニア
2021年10月に、パーソルプロセス&テクノロジーからパーソルキャリアにグループ内で転籍。 パーソルプロセス&テクノロジー時代から、転職サイトサービス『doda』やその周辺システムの開発・保守に従事。現在はdodaアプリ開発チームのスクラムマスター兼エンジニアとして、チーム運営プロダクトの改善に取り組んでいる。
※2022年1月現在の情報です。