ITサービスの高可用性と耐障害性 (HA/DR) を向上させるには ― 楽天ペイ(オンライン決済)での取り組み② ―

楽天ペイ(オンライン決済)で Tech Lead をしている Nana です。今回は私たちが運営する「楽天ペイ」での高可用性/耐障害性 (以下、HA/DR)を向上させるための具体的な取り組みを紹介していこうと思います。

前回の記事では、私たちが第一弾として「楽天ペイ」という大規模サービスを運営・管理する中で培ってきたアプローチ方法をより一般的な説明として取り上げました。興味のある方は是非合わせてご覧になってください。

HA/DRを向上させるための5ステップ

前回の記事でもHA/DRを達成していくための5つのステップについてはお伝えしていますが、再掲です。「楽天ペイ(オンライン決済)」を運営している私たちのチームでも以下の5つのステップ通りに沿って、HA/DRを向上させています。

5ステップとは?

Step 1. HA/DRにおける目標を設定:事業継続計画 (BCP) における3つの(RLO/RTO/RPO)を設定する

Step 2. Portability (移植性)開発したアプリなどの資産がどのような環境でも容易に動作し、また動作環境の再現も容易である状態を目指す

Step 3. Retry-Ability (リトライ容易性)処理フローにリトライ処理を含める、あるいはユーザー操作によるリトライ処理を可能にするなど、リトライ処理が容易に行えるシステム設計を目指す

Step 4. Redundancy (冗長性)システムを多重化することで、一部のシステムが不具合などで正常に動作しない場合でも、全体を通してシステムが正常に稼働する状態を目指す

Step 5. Resiliency(回復性)システムが完全にダウンしてしまった場合でも復旧できるような状態を目指す

詳細は、前回の記事をご覧ください。

Step 1: HA/DRにおける目標を設定する

※社内戦略や具体的な数値などが含まれてしまうため割愛します。

Step2: Portability (移植性)を向上させる

私たちの場合、機能やサービス毎にアプリケーションレベルではコンテナ化、インフラストラクチャレベルでコード化できる部分をそれぞれ整理し、移植性を担保する取り組みを行なっています。

1. アプリケーションレベルでのコンテナ化

主にアプリケーションレベルでのモジュール化とは、コンテナ化を意味します。コンテナビルダーとしては、最新のトレンドとしてはBuildpacksの利用もあり得ますが、私たちは現在Dockerfileを用いてイメージ化しています。

Dockerfileを利用する場合は自由度が高い分、セキュリティ、再現性の観点で事故が発生しやすくなります。防止策として全アプリケーションが共通して利用するベースイメージを準備しておき、それらを各アプリケーションで利用することで事故を防ぐ工夫を行っています。

2. インフラストラクチャレベルでのコード化

インフラストラクチャにおけるコード化は、Infrastructure as Code(以下、IaC )とも呼びます。IaCの取り組みを進めた上で、サービスの拡大と共に徐々にインフラ構成をモジュールとして利用できるよう準備すると良いでしょう。

私たちの場合、Terraformを利用してActive・Standby用の複数環境のインフラ構成を一括で管理しています。統一化されたモジュールを利用することで最小の設定で効率的に複数の環境を構築・管理することができます。

IaCを進めていく上で重要なことは、手作業を廃し、メンバー全員にインフラストラクチャに対する理解を深めていくよう促すことです。手作業は再現性を低めバグや想定外のエラーを発生させる原因となります。手作業を排除していくためには、全てのエンジニアのインフラストラクチャに対する苦手意識(特にNW、セキュリティ周り)を取り除き、メンバー全員がIaCへの理解を深めた状態となるのが理想です。

私たちのチームでも、約2年の歳月をかけて徐々にチームメンバーへIaCを基準とした開発体制を浸透させていく事ができました。例えば、インフラの構成変更が発生するような場合、アプリケーションエンジニアに対してインフラエンジニアがレビュアーとして協力できる体制を作る、あるいは機能開発前にアプリケーション・インフラエンジニアを交えて設計する等、小さな成果を積み重ねてきました。それでも常に改善の余地があるため、現在でもメンバー同士でさらに良いインフラ構成について考えています。

Step3: Retry-ability (リトライ容易性) を向上させる

「楽天ペイ(オンライン決済)」の場合では、エラーが発生した際でも単にデータの不整合がなく処理前の状態へリカバリさせるだけでなく、データ処理を完了させた状態へ前進させることを目標としています。

一般的にはいくつかのリトライに関わるデザインパターンがあり、例えば補償トランザクションなどではエラーが発生した際には処理のキャンセルをかけていきますが、私たちのサービスでは、なるべく決済処理を完了させられるよう一度依存サービス側のステータスを確認し、処理に進めるか戻すかを決定するようなロジックを取っています。そのため場合によっては、再度リクエストを投げることなく意図した処理が完了させられます。

また依存サービスとの通信が断続的に失敗しているケースでは、キャンセル処理の通信が失敗してしまうので補償トランザクションのみではデータの整合性を完全に担保することはできません。そういったケースを想定し、別プロセスで定期的に依存サービスとの不整合を解消するための突合処理(Reconcile Process)を非同期処理として行い、補完的にデータの整合性を担保しています。

Step4: Redundancy (冗長性) を担保する

私たちの場合、パブリッククラウドを利用しているため、インフラレイヤーでは基本的にはAvailability Zone(以下、AZ)を有効にした上で、インスタンス数を2以上にすることで冗長性を担保しています。アプリケーションレイヤーではAZにまたがるKubernetes (以下、k8s)上で動かしながら、インスタンス数を2以上にすることで冗長性を担保しています。しかし、実はこれだけではアプリケーションレイヤーの可用性は完全ではありません。

k8sでは更に可用性を上げるため、以下のような設定を行っています。

設定項目 設定で防ぎたいこと・やりたい事
PodDisruptionBudget ノード故障で再起動時したいときに、Podが同時にダウンすることを防ぐ
ResourceLimit 特定のアプリケーションによる負荷が上昇した場合、他のアプリケーションの性能に影響を及ぼさないように占有できるリソースを制限する
LivenessProbe 正しく動作していないPod(Broken Pod)がそのまま実行状態となりリソースを占有したままの状態となることを防ぎ、再度処理を実行させる
ReadinessProbe 処理が失敗する可能性が高いPodを安全に切り離す
NodeSelector & NodeAffinity 特定の負荷が高いバッチとエンドユーザーに影響するフロントアプリケーションを物理的に分割し、エンドユーザーの利用に影響が出ないようにする
PodAntiAffinity あるアプリケーションを稼働させるためのPodを複数Nodeをまたいでデプロイされるようにし、1つのNodeで障害が発生した場合でも、サービス全体が停止しないようリスクを分散させる

上記の設定については、以下のようなDeployment Yamlファイルにて設定が可能です。

Deployment Yaml

apiVersion: policy/v1 kind: PodDisruptionBudget ... spec: minAvailable: 50% selector: matchLabels: app: <app-name> ... apiVersion: apps/v1 kind: Deployment ... spec: replicas: 2 template: spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key: app operator: In values: - <app-name> topologyKey: "kubernetes.io/hostname" nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: app-category operator: In values: - web containers: - name: <app-name> … resources: limits: memory: 1024Mi cpu: 1000m readinessProbe: ... livenessProbe: ...

Step5: Resiliency(回復性) を向上させる

私たちは現在、複数のシステムを複数用意し待機させておくActive-Standbyの構成としていますが、一般的には以下のような3つの区分に分けて環境を準備します。事前にStep1で設定した目標復旧時点(以下、RPO)や、どの程度の運用コストや復旧時間 (以下、RTO)を要するのかなどを計算し、最適な施策の組み合わせを検討した上で、実装しました。

HA/DRを進める上ではRTO/RPOと運用コストの間の費用対効果を高めるために、3つの区分をどのように組み合わせて環境構築するかがとても重要です。

区分 定義 運用コスト 復旧にかかる時間
On-Demand
(Cold Standby)
事前にバックアップ環境のコード化をしておくことで、必要に応じてTerraformでProvisioningをする。
*一般的Cold Standbyですが、クラウドでは停止=削除となる場合がある為、On-Demandと定義しています。
Warm Standby 事前にバックアップ構築をしておき、停止もしくは縮退運用にする。
Hot Standby 事前にバックアップ構築を構築し、常に起動させておくリソース。 無~小

また忘れがちですがOn-Demandで事前にバックアップ環境をコード化する際には、セキュリティ監査やNW系の設計・疎通を受けた上でリソースを削除して、再現性が高く安全にゼロから作ることを保証しましょう。

最後に本番環境と待機系の参考システム構成を図示します。特筆することとしては、ユーザからのリクエストはGSLBで常に受け付けており、また環境を速やかにスイッチできるよう本番・待機系の両環境へ事前に待機系のIPやドメインの情報を含むよう設計し、GSLB に登録させておきます。

k8sのwarm standbyについては想像がつきにくいかと思いますが、マスターノードのみを維持してアプリケーションはデプロイせずワーカーノードを0台として縮退運用をしています。もし問題が本番環境で発生した場合には、エンジニアは待機環境のリソース群の作成や、本番環境への昇格等を行った後にGSLBからの経路を変更することでシステム回復を目指します。

またいつ問題が発生した場合であっても速やかにバックアップ環境から本番環境への移行作業が行えるように、定期的にシステムの防災訓練をかねた手順の検証を行っています。

まとめ

いかがだったでしょうか。今回は、楽天ペイ(オンライン決済)で実際のインフラ構成に落とし込んだ場合での例をご紹介しました。

前回の記事と併せて、これらの記事が少しでも皆さんが実際にアクションを起こすための参考となれば幸いです。ここまで読んでくださってありがとうございました!

採用情報

私たちは一緒に開発をしてくれるメンバーを募集しています。詳しくは以下のリンクを参照ください。

採用情報:楽天ペイ(オンライン決済)

ITサービスの高可用性と耐障害性 (HA/DR) を向上させるには ― 楽天ペイ(オンライン決済)での取り組み① ―

初めまして、「楽天ペイ(オンライン決済)」で Tech Lead として働いているNanaです。今回から2記事に分けて、私たちが取り組んでいる高可用性/耐障害性 (以下、HA/DR) の仕組み作りについて紹介していきます。

今回の記事では、私たちがこれまで「楽天ペイ(オンライン決済)」という大規模サービスを運営・管理する中で培ってきたHA/DRを高めるためのアプローチ方法を説明していきます。

また次回の記事では、私たちが運営する「楽天ペイ(オンライン決済)」でのより具体的な取り組みについても紹介する予定です。サービスのHA/DRを高めたいと考えている方は、是非ご覧ください。

楽天ペイ(オンライン決済)とは

私たち「楽天ペイ(オンライン決済)」では決済サービスを楽天市場以外のECサイトに提供をしています。エンドユーザ視点では、クレジットカード情報の入力といった面倒な作業をする事無く会員情報に事前に登録されているクレジットカード情報を利用したり、楽天ポイント・楽天キャッシュの残高を利用したりして簡単に決済することができます。

コロナ禍によってECサイトを利用する人が増えた後も継続してECサイトでの買い物をして頂けるようになったこともあり、おかげさまで楽天ペイ(オンライン決済)を選んで利用して頂ける方も増加を続けております。どのようなサイトで使えるのかは、以下のリンクをご参照ください。
使えるサイトについてはこちら

なぜHA/DRの向上が私たちにとって重要なのか

さて、ここからが本編です。上記の通り「楽天ペイ(オンライン決済)」は楽天市場以外の EC サイトへも提供されています。もし私たちのサービスが停止すると、ECサイト側でも決済不可となり購入者様にご迷惑をおかけすることはもちろん、ECサイト側の売上を落とすことにも繋がってしまいます。

そのため、私たちは根幹となる「楽天ペイ(オンライン決済)」のHA/DRを高めることを重視して日々開発・運用を行っています。

HA/DRを向上させるための5ステップ

HA/DRを達成して行くためには上記のように大きく5つのステップがあります。それぞれのステップでの取るべきアクションはサービス規模や運用にかけるコスト、チーム体制などによって変わるので、一つ一つのStepを順番通りに確認してください。

Step1: HA/DRにおける目標を設定する

まず運用コストやチーム体制は事業継続計画 (以下、BCP) と密接に関わっているので、BCPで定義されている以下の3つの観点と費用について関係者と必ず認識を合わせましょう。

目標設定は現在のサービスのビジネス状況によって変化していきます。定期的に見直し、現在の立ち位置を考慮した目標を設定しましょう。

目標 目標の内容 決定のための要素
RLO (Recovery Level Objective) / 目標復旧レベル 最低限動かしたい機能は何か?
最低限どの程度の稼働率を目指すか?
エンドユーザに使ってもらう主要機能は何か?
一時的に手運用で動かせる物や復旧を待てる業務はあるか?
RTO (Recovery Time Objective) / 目標復旧時間 どの程度の時間で復旧を目指すか? 楽観的なシナリオではどのくらいの時間がかかるか?
悲観的なシナリオではどのくらいの時間がかかるか?
停止している間の機会損失・社会的インパクトはどの程度か?
財務的にかけられる運用コストは?(掛け捨て保険)
RPO (Recovery Point Objective) / 目標復旧時点 データの損失は、いつのタイミングまで許容できるか? データの更新頻度はどの程度か?
損失するデータは別の手段で復旧が可能なデータか?
データの保持に対して関連するような法令・規則等はないか?

これら上記の3つのR、RLO/RTO/RPOはシステムの複雑度と運用コストに大きな影響を及ぼします。また万が一の障害発生時には、一部機能やデータを諦めるという決断を下さなくてはいけない場合もあります。そういった有事の際を含めた想定を行いながら、目標設定を行ってみてください。

特に後述するStep5 Resiliency (回復性)を向上させる為のアーキテクチャ設計には大きく影響するため、後回しにせず事前に目標設定を完了していることが望ましいです。

Step2: Portability (移植性)を向上させる

Step2ではPortability (移植性)を向上させていきます。移植性とは、開発したアプリなどの資産がどのような環境でも容易に動作し、また動作環境の再現も容易であるかどうかの程度を指します。移植性を向上させる主な目的は、「デリバリーの高速化」です。具体的にはコンテナ化やInfrastructure as Code (以下、IaC)の取り組みが当てはまります。

HA/DRを進めて行く上では、移植性の向上はチームとして取り組むべき最初の課題であり、開発スタイルや費用対効果といった課題にぶつかる最初の壁にもなります。

コンテナ化は比較的取り組みやすいと思いますが、IaCを取り組む場合には特に管理・教育コストの面で施策の難易度が高まります。最初に取り組むことをお勧めする理由としては、以下のような理由が挙げられます。

1.後続Step3~5の取り組みは、移植性を前提として進めて行くため。
2.後続Step3~5の取り組みが不十分であったとしても、費用と時間をかければ有事の際に復旧を目指すことができるため。

Step3: Retry-Ability (リトライ容易性) を向上させる

Step3では、Retry-Ability (リトライ容易性)を向上させていきます。この用語は私たちの造語ですが、以下の2つの観点がポイントです。

1. システム面では常にリトライ処理を実装し、リクエストが正常に処理されることを担保する

可用率をどれだけ改善しても100%とはなりません。ネットワークの瞬断や通信の滞留、フェイルオーバに伴う瞬断の発生を完全に防ぐことは難しいためです。そのためI/Oが発生する箇所(通信・ファイル書込等)では必ずリトライ処理を実装すると良いでしょう。

ただリトライ処理は単純なようでいて、実はデータの不整合を引き起こす原因になり得ます。たとえばConnection Timeoutとは違い、Query (or Read) Timeoutの場合はリクエストが送信され、処理が完了している可能性があります。

場合によっては後続処理を正とする単純なリトライ処理の実装のみで、十分に整合性を担保できることもありますが、より厳密なデータの整合性が求められる場合、モノリシックサービスであれば「トランザクション管理を行う」、マイクロサービスであれば「補償トランザクション」や「TCC (Try-Confirm/Cancel)パターン」等を作り込んでいく必要があります。今回は参考として、補償トランザクションを利用したリトライ処理の場合でのシーケンス図を以下に図示します。

[補償トランザクションの実装例]

2. ユーザが簡単にリトライ処理を行えるようなシステム設計を行う

システム側でリトライ処理を実装していても、リトライをしきれないケースも発生します。そういった場合でも、後続処理がデータの不整合による影響を極力受けないようフェールセーフに配慮することが重要です。

対策の一例としては、後続処理を素早く再実行させるために利用者自身でも容易にリトライ処理を再実行できるようなシステム設計を行うなどがあります。そのためには、エラー発生時の処理フローや画面上の振る舞いと言った観点でもシステム設計を作り込んでおくと良いでしょう。

Step4: Redundancy (冗長性) を向上させる

Step4して、Redundancy(冗長性)を向上させていきます。冗長性の向上とは、システムを多重化することで、一部のシステムが不具合などで正常に動作しない場合でも、全体を通してシステムが正常に稼働する状態を目指すことです。

ただ冗長性を向上させる施策を行うことで、切り替えや切り離し処理が追加され、新たな瞬断の発生原因となる可能性があることにも注意が必要です。冗長性を向上させるための観点は以下の2点です。

1. High Availabilityを意識したインフラ構成を構築する

まずは、地道に単一障害点となる箇所をみつけ多重化・クラスタ化を進めていきましょう。
単一障害点とは、システムを構成する要素のうち、そこが停止するとシステム全体が停止してしまう部分のことです。

このとき多重化をする上での重要なポイントは、「NW・電源系統・仮想化サーバ等」が物理的に分離されていることです。これらの要素が分離されていることで、システムの可用性を担保することができるようになります。またクラスタ化を行う場合、アプリケーションのインスタンスが物理的に異なるサーバ上でも動作可能である必要があります。

サービスによっては、オンプレミスではなくパブリッククラウドなどを利用しているケースもあるかも知れません。その場合は、Availability Zoneを適切に設定することで可用性の高いインフラ構成を実現できます。

以下では、単一障害点となる可能性があるポイントをいくつかピックアップしてみました。まずは読者の皆さんも同様の観点でチェックしてみてください。


2. 冗長性を維持する運用手順を確立しよう

仮に冗長性の高い構成を組んでいても、ダウンしたサーバやアプリケーションへリクエストが送られる状態が長く続くとリクエストの成功率は低下します。そのため、素早く切り離しやフェイルオーバを行えるかといった運用手順の確立も重要なポイントです。

サービス開始時などは手動対応でも良いですが、運用を行いながら効率的な切り離しやフェイルオーバを行う手順を確立させ、最終的には運用手順を自動化させていけることが理想です。

Step5: Resiliency(回復性) を向上させる

Step5ではResiliency(回復性)を向上させていきます。回復性ではシステムが完全にダウンしてしまったときからでも復旧できるような状態を目指していきます。

これまでのStepでシステムを継続的に正常稼働させるためのポイントを幾つか紹介してきましたが、それでも常に、大規模な災害や停電などシステム全体が停止することが避けられないケースは存在します。そのような場合でも素早くサービスを復旧できるよう準備しておきましょう。

回復性を向上させる具体的な施策としては、「バックアップを確保する」「距離的に離れた地点でシステムを動かす」などが考えられます。実装する施策の選定には、施策実施時の運用費用、サービス復旧時間とサービス停止時の損失額などを試算し、施策毎の妥当性を検討する必要があるでしょう。例として、各施策実施時の運用費用とサービス復旧時間についての関係性を調査した際のグラフを以下に示します。

※データや数値はあくまで参考値です

上記のグラフからも、システムの規模やサービス停止時の損失額により、サービス毎に取るべき施策やKPIが異なってくることがわかります。復旧時間が短い強度の高い施策の場合、その分運用コストも肥大化していくため、サービスの成長・拡大のフェイズとともに徐々にActive-Activeを目指していくことが現実的な施策選定と言えそうです。

また非常時でもシステムの回復手順(フェイルオーバの手順等)を作業者が素早く実行できるよう、定期的に手順書のアップデートやITシステムの防災訓練を行うなど、日頃から非常時の準備を行うことが重要です。

まとめ

ここまで読んで下さってありがとうございました。この記事を通して、読者の皆さんがHA/DRやBCPをより深く理解し、少しでも実際のアクションに繋げて頂けたら幸いです。

次回は、ここまで紹介してきたアプローチ方法を「楽天ペイ(オンライン決済)」の開発現場で実際にどう活かしているかについて具体例を交えて紹介していきます!

採用情報

私たちは一緒に開発をしてくれるメンバーを募集しています。詳しくは以下のリンクを参照ください。

採用情報:楽天ペイ(オンライン決済)

ラクマiOSアプリのフルSwift化を約4年かけてやり遂げた話

こんにちは。楽天ラクマ モバイルアプリケーショングループのdarquroです。

今回は「楽天ラクマ」iOSアプリのフルSwift化を、約4年かけてやり遂げた話を書きたいと思います。

ことの始まり

私は2018年10月1日に楽天グループに入社しました。 当時採用面接で驚いたのは、アプリエンジニアのチーム体制を聞いたところ、iOS1名、Android1名という状態だったことでした。

なので、私がiOSエンジニアとして入社し、やっとiOSアプリは2人体制になったというわけです。

2018年は「楽天ラクマ」の前身である「フリル」を運営する株式会社Fablicを吸収合併し、それに伴い開発組織としても再構築していく時期でした。 そういったチャレンジングなタイミングに入社を決めたわけではありますが、iOSアプリのコードの状況はというと、Objective-Cという大きい技術的負債を抱えており、なかなかメンテナンスをしていくのが大変だと感じました。

私としては、Objective-Cの経験もありましたし、今後もメンバーを増やしてプロダクトの安定的に開発していくためにも、フルSwift化をリードしてやり遂げることが目標の一つでした。

なぜ4年近くもかかったのか

結果として、最後のObjective-Cクラスを削除するPRをマージできたのは2022年8月3日でした。

そして、今回この記事を書くにあたり、Swiftが登場した2014年以降、「楽天ラクマ」のSwiftとObjective-Cの比率を時系列でグラフにしてみました。

最初にSwiftのコードが入ったのは、2015年9月頃だったことがわかりました。 Swiftのバージョンでいうと1.2と2.0の間くらいですね。

私は2018年入社なので、それ以前のことはわからないのですが、2016年から2017年に一度大きくリファクタリングされており40%まで引き上げられていました。 2020年からまた引き上げは、2人だったiOSエンジニアが4人、5人・・・と優秀なメンバーを採用することができ、安定してリファクタリングに時間を避けるようになりました。

Swift化にどうしてこれだけの時間がかかったのかは、様々な要因があります。

  • Objective-Cで作られたクラスがかなり多い
  • いくつかの巨大なObjective-Cのクラスがあらゆる画面で参照されており、依存関係が複雑かつ密結合
  • テストコードはほとんどない
  • 機能開発は止められない
  • エンジニアが少ない(当時)

私が入社した2018年10月当時にリリースしたv7.6.0のコードをcloneしてきて、コードの言語比率を出してみると、以下のような結果でした。

-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
XML                            139            250             54         102789
Objective-C                    180           6605           2193          29105
Swift                          357           5530           3112          26257
JSON                           121              0              0           9861
C/C++ Header                   173           1114           1612           2426
Markdown                        10            166              0            425
YAML                             1              0              0             16
-------------------------------------------------------------------------------
SUM:                           981          13665           6971         170879
-------------------------------------------------------------------------------

Objective-Cが50%を超え、かなりの量のコードが残っていました。 それでいてテストコードは一部のみで、各画面はほとんどがViewControllerにすべてのロジックが書かれているような状況でした。

さらに、機能開発を止めずプロダクトを成長させていかなければならないため、レガシーコードをどうリファクタリングしていくのか、戦略を立て段階的に移行していく必要がありました。

リアーキテクチャーの方針を立てる

まずは大規模なリアーキテクチャーを2019年頃に行いました。 それまでは各画面のViewControllerと、ModelというディレクトリにEntityクラスが入っていて、それ以外は○○Managerといったクラスが複数あり、APIリクエストは巨大な1クラスの中に全APIリクエストのロジックが書かれていました。

そこで、PresentationレイヤーとDomainレイヤーとを分け、PresentationレイヤーはMVPのアーキテクチャーを採用し、DomainレイヤーはUseCase、Repository、DataStore、Entity、APIClientといったレイヤーで分けていく方針を立てました。

現在のアーキテクチャーは更にSwiftUIとMVVMも混在したアーキテクチャーになっており、詳しくは以前勉強会で発表した資料もご覧ください。

speakerdeck.com

また、ViewControllerについては今もUnitTestは基本書いていないのですが、それ以外のレイヤーはUnitTestを書いていく方針で進めていきました。

スタンダードを選択することのメリット

ここで少し余談にはなりますが、Presentationレイヤーのアーキテクチャーについては、2019年当時はRxSwiftも流行っており、多くのアプリでRxSwiftを使用したMVVMアーキテクチャー採用していたと思います。「楽天ラクマ」はObjective-Cからの移行をしなければいけなかったため、なるべくFoundationやUIKitの標準のフレームワークと親和性の高いMVPを採用しました。

Combineが存在している現在から考えるとRxSwiftをもし導入していたらそれからの移行が発生していたので、正しい選択だったと思います。

また、「楽天ラクマ」はCarthageが登場してからもライブラリ管理はCocoaPodsだけにまとめていました。(現在は概ねSwift Package Mangerに移行しています) Xcode12でCarthageのライブラリがビルドできない問題があり、多くのアプリで対応に追われていたなか、「楽天ラクマ」はスムーズにXcode12移行ができたことがありました。

そして、一度XcodeGenの導入を検討し、調査を進めていたこともありました。 ビルドできるまでの道のりが大変だったり、Bitrise上でビルドすると失敗することが最終的に解消できず、自分達では今後メンテし続けることは困難と判断し、導入しませんでした。

現在もアプリのモジュールを分けていないため、開発しているとproject.pbxprojファイルのコンフリクトはよく発生しますが、解消方法自体は単純なため、コンフリクトを修正しながら乗り切っているという状況です。

これについては、Swift Package Managerが登場してモジュールの切り出しをSwift Packageとして管理することで、project.pbxprojファイルのコンフリクトを減らせることもできるので、今後はその方針も検討しています。 他社様のアプリ事例でXcodeGenを引き剥がすのにとても苦労したという話も聞いていましたので、もしXcodeGenを下手に導入していたら、それのメンテナンスに追われていて、開発がストップさせてしまう状況になっていたかもしれません。 (とはいえXcodeGenの調査を経て、不要なプロジェクト設定を精査すること繋がりました)

RxSwift、Carthage、XcodeGenなどの当時の先端技術を選択せず、iOS開発のスタンダードな手法を選択してきたことで、少ないメンバーの時期も安定的な開発体制を維持できたと思っており、技術選定はとても慎重に行うべきだと教訓になっています。 例え少し先進的なものを導入しても、ロールバックや別プランに切り替えることができると見通せていることが大切だと思います。

巨大であらゆる画面に依存したクラスのリファクタリング

「楽天ラクマ」には数千行からなる巨大で、あらゆる画面に依存したObjective-Cのクラスが複数存在していました。

例えば出品画面や検索結果画面といった「楽天ラクマ」の主要機能もObjective-Cで、FatViewControllerとなっていました。 また、"商品"や"検索条件"といったドメインのモデリングしたクラスも巨大なObjective-Cクラスで、「楽天ラクマ」は商品を中心としたアプリですので、あらゆるクラスに引き渡されているため、複雑な密結合を紐解いていく必要がありました。 (2022/8/4時点の画面)

レガシーで巨大に膨れ上がったクラスは、そのクラス自体のテストコードがなく、テスタブルな設計実装がないため、そのクラスに依存したクラスもまたmockやstubを用いたテストが書けない状態でした。

巨大なクラスはメソッド単位や機能別のSwiftクラスとして分解して、その分解したクラスに対してテストコードを書き、巨大クラスの依存を減らしていくという手法を取りました。

単純にObjective-CのクラスをSwiftクラスとして書き直して一括置換することは機能開発を続けていくためには不可能に近く、依存が少なくなった段階で残りを置換することで、テストのカバレッジを徐々に上げ、安定性を担保しながら移行できたと思います。

作業分担の可視化

リファクタリングを進めていた当初は人数も少なかったですし、口頭で何をリファクタリングするか確認しあうだけで十分でしたが、メンバーが増えてきた段階で、残りのファイル一覧と、誰が担当するか、何と依存しているかなど、作業分担の可視化も行いました。 基本的なことかもしれませんが、すぐに状況が見えることはリファクタリングを進めていくうえで、そのスピードを上げていくのにとても重要でした。

それでも発生する不具合

ユニットテストもしっかり書き足していきましたし、「楽天ラクマ」ではQAチームが開発チームとは別にあり、組織横断のナレッジを活かしながら自動テスト、手動テストを実施してくれています。 毎回のアプリリリースはQAチームにもテストを細かく実施してもらいましたが、それでも不具合は出てしまいました。

特に難しかったのが、Objective-Cでもnullablenonnullの定義を書けるようになっていたものの、あらゆる画面で使われているクラスに導入すると変更差分が大きすぎて、導入が後手に回ったことです。 それにより、SwiftのコードからだとString!のようにOptional型だけどunwrapせずそのまま参照できてしまう(Implicitly Unwrapped Optionals)ため、型の確認をしつつunwrapしていく必要があり、それが漏れていることでnilが入りクラッシュしたり、逆に余計なところまでguard文を挿入してしまったことで、挙動が変わってしまったといったことなどがあります。 こういったケースは正常ケースでは見つけにくく、特定の条件が揃った場合や異常ケースでのテストで網羅できていないことが原因でした。

こういったテストの漏れに対する対策としては、漏れていたユニットテストを追加するだけではなく、以下のようなこともしました。

  • レビュアーの数を最低1名から最低2名に増やす(これはチームの人数が増えてきたことで可能となった)
  • すべてのPRに対し、インパクトレベル(4段階)を設定し、QAチームに共有する。最もインパクトレベルの高いLevel4についてはTech Leadが必ずコードレビューに入る
  • リファクタリングのスピードを落とす

リファクタリングのスピードをあえて落とすという選択

Swift比率が95%を超え、最終局面といったフェーズでは、リファクタリングのスピードをあえて落とすという選択もしました。

スピードを落とした理由は、短期間の間に不具合が連続してしまったからです。 明確な因果関係が難しいのですが、リファクタリングが進むにつれ、チームとしても速く進めたいという気持ちの高まりや焦りのような空気感もありました。 そういった空気感の中で、巨大なPRや大量のPRにより細かなレビューが行き届かなかったり、結果QAチームにも影響範囲が正確に伝わらなかったりといった結果に繋がっていったと感じています。

一つ一つの不具合には直接原因として実装考慮漏れ、テスト漏れといったものがありますが、根本的にはチームの空気感も影響していると考え、一旦クールダウンさせ、最後の詰めを丁寧にやっていこうと舵を取ったことで、顕著に増えてしまったリファクタリングによる不具合はなくなっていきました。

システムも作っているのは人間ですので、その変化や影響は人間の感情から少なからず受けると思います。 チームの空気感やカルチャーもシステムの品質向上には大切だと学びました。

おわりに

このObjective-Cのリファクタリングは優秀なメンバーが奇跡的に揃い、全員で協力して進めていくことで成し遂げることができました。 メンバーのみんなには本当に感謝していますし、自分のエンジニア人生の中でも、共に戦った戦友として大きな信頼を寄せ、そして誇りに思っています。

約4年という長い道のりでしたが、レガシーコードとの一つの戦いに決着をつけ、新たなステージへと向かうよい区切りを付けることができました。 これからもチャレンジをし続け、より良いプロダクトにしていきたいと思っています。

「楽天ラクマ」では、Ownershipをコア・バリューの一つに掲げ、一緒に開発をしてくれるメンバーを募集しています。 興味を持っていただいた方は、ぜひカジュアル面談でお話できればと思います。

www.wantedly.com

ふりかえりの大切さ

みなさん、こんにちは。楽天大阪支社でお仕事させていただいているイデルと申します。今回は「ふりかえり」についてお話したいと思います。

数年前、私はソフトウェアエンジニアからプロジェクトマネージャーにキャリアパスを変更し、それから様々なフレームワーク、方法論やプロジェクトをハンドリングする方法、チームとの関わり方を試してきました。その話は次のR-Hackの記事で扱うことにして、この記事では「ふりかえり」に焦点を当てていきたいと思います。

ふりかえりとは、製品をリリースした後に行われるミーティングのことで、開発の中で行われたことを話し合い、その学びや会話をもとにその後改善していくことを目的としています。

ふりかえりは大事ですか?

とても大事です。

  • チームは常に改善していく必要があり、それなくしてはチームが停滞してしまいます。

  • チームのベストプラクティスを発見し、継続するようにしてください。

  • ふりかえりはお互いに感謝を贈り合う絶好の機会でもあります。

  • 良い面に焦点を当て、さらに改善できる方法を探りましょう。

  • 名指しや批判をしてしまっては、ふりかえりの本来の目的を見失ってしまいます。

また、チームはそれぞれ異なります。多様性のあるチームで仕事をしている場合、他のメンバーの文化的背景を踏みにじったり、誤って侮辱したりしないように、境界線を設定するようにしましょう。

ふりかえりやその重要性に関して基本的に理解できたところで、私の経験について詳しく説明していきます。

ふりかえりを知ったきっかけは何でしたか?

およそ15年に渡り、私は主にミドルウェアの開発を行ってきましたが、それはウォーターフォール型開発で、チームはアジャイルもスクラムも全く使ったことがありませんでした。職場の同僚と会話するのは仕様検討や質疑応答の時ぐらいでした。転職して楽天のチームに入った時、私は初めてアジャイル、スクラム、朝会やふりかえりについて知りました。これらについてそれまで全く経験が無かったので、理解するために勉強しなければいけませんでした。

最初のふりかえりはどうでしたか?

何が起きているのかよくわかっていませんでした。気付いたことを付箋に書くように言われ、その後一つ一つについて議論を行い、グループ化しました。誰かがその写真を撮り、コンフルエンスのページを作成し、それで終わりました。その後、そのことについては何も聞いていません。 イベント自体はまあまあ良かったと思います。メンバーの大半は静かでほとんど何も話さず、議論を独占していた人が何人かいました。あまり充実した30分ではなく、その時はふりかえりはただの時間の無駄だと思いました。

「時間の無駄」だと思った気持ちは、いつ、どのように変わりましたか?

チームで組織変更とプロセス改善が行われたため、私はチームマネジメントのさまざまな方法を学ぶことができました(私はマネージャーではありませんでしたが)。また、ふりかえりを行っている別のチームに参加する機会もありました。 後者の方が考え方が変わる大きな要因だったかもしれません。他のチームがどのようにコミュニケーションを取り、改善のために意見やアイデアを共有し、それらがきちんと実行されたか、チームにどのような影響を与えたかを確認することができました。

もちろん、全てのチームが良かったわけではなく、ふりかえりで口論や非難をして生産的なアウトプットのないチームもありました。

ふりかえりの善し悪しは、チームによるふりかえりの捉え方や、ファシリテーションに大きく依存します。

おすすめのスタイルはありますか?

チームが心地良いと思うやり方が良いです。私が試したスタイルがいくつかあるので、紹介していきます(おすすめ順というわけではないです)。

Spotifyの「Squad Health Check – 同僚がこの方法を紹介してくれたのですが、チームが現状を可視化し、議論し、評価することで、アクションプランを明確にできるという点で大変良いと思います。私はコロナ禍前にカードをダウンロードして印刷し、ラミネート加工してチームに配りました。何度かこの方法を試しましたが次第に慣れてきて、本当に改善しなければいけないことに焦点を当て、現実的なアクションプランを立てることができるようになりました。成果をより明確にイメージできるように、月に1、2回このスタイルを使用することになりました。

残念ながら、コロナ禍もあり、直接会っての会は開催できていません。ある同僚は、Kahoot(?)でゲーム版を作ってくれました。私はというと、もっとシンプルな日本語版が欲しかったので、Microsoftのアンケート方式のものを作りました。日本語版はこの記事の最後の方で紹介します。

Squad Health Checkの結果

Moving Motivators – マネージャーとの1on1にはこれが一番だと言う人がいるかもしれませんし、私もそう思うのですが、実際はチームでやるのが楽しいです。私達は毎月やってみて、チームの状況や個人の気分によって、モチベーションが変わることがわかりました。毎月の記録を一箇所に集め、変化を簡単に把握できるようにしていました。また、マネージャーには各チームメンバーのモチベーションを共有しました。

2019年3月開催のふりかえりにおけるモチベーション

2019年7月開催のふりかえりにおけるモチベーション

2019年12月開催のふりかえりにおけるモチベーション

自分の一番のモチベーションが一年を通してずっと変わらなかったことが興味深かったですね。

最下位というわけではありませんが、最後は KPT(Keep Problem Try) です。 歴史を紐解けば、この方法はトヨタ自動車が継続的な改善を行うために開発しましたが、今でも十分シンプルで便利です。まだふりかえりをしたことのない新しいチームには有効なスタイルだと思います。しかし、ふりかえりを継続するうちに少し単調に感じてくると思うので、先に紹介したSpotifyの「Squad Health Check」など、他のスタイルと組み合わせると良いでしょう。

様々なスタイルのふりかえりがありますが、コロナ禍においても相性の良い方法を試してみてください。今はふりかえりをするのが難しくなりましたが、きっと良い方法があるはずで、たくさんのチームがすでに色々なオンラインツールを使ってチーム改善の方法を模索しているはずです。MiroやIdeaboardzなんかが、良いツールの一例ですね。 MiroIdeaboardzなんかが、良いツールの一例ですね。

結果はマネージャに共有していますか?

もちろんです。チームだけでは実行できず、マネージャーのサポートが必要なアクションプランが出てきた場合、それが適切な人に届くようにするのはグループリーダーの役目です。ただし、メンバーがマネージャーから非難されたり標的にされたりすることがないようにしてください。各個人の意見が、評価に影響しないようにしなければいけません。

マネージャーとメンバー、お互いの信頼が大事です。ふりかえりを積み重ねていくことでメンバーが意見を言い合える心理的に安全な場が作られ、信頼関係を築いていけるのです。

免責事項: ここに記載している情報は全て個人の経験に基づく感想となり、全ての方に当てはまるわけではありません。アジャイルやスクラム、多様性のあるチームで働くことに関する書籍は多くあります。

一緒に働く仲間を募集しています

最後まで読んで頂きありがとうございました。 楽天のECインキュベーション開発部では、事業成長の原動力となり、共に新しい価値を創造し、育てていくことができる方を募集しています。この記事を読んで興味を持っていただいた方はぜひともご応募お待ちしております。

japan-job-en.rakuten.careers

付録:Spotifyの「Squad Health Check」日本語版

Delivering Value・価値の提供

  • 青:誇りを持って良いものを届けています。ステークホルダーも満足しています。
  • 黄:変化なし。
  • 赤:残念なものを届けています。恥ずかしいです。ステークホルダーにも嫌われています

Easy to Release・リリースが簡単

  • 青:リリースはシンプルで安全で簡単で、ほぼ自動化されています。
  • 黄:悪くない。
  • 赤:リリースはリスクが高くて、大変で、手動でやることがたくさんあり、とても時間がかかります。

Fun・楽しい

  • 青 : チームと一緒に仕事をするのはとても楽しいです。
  • 黄:悪くない
  • 赤: つまらない。。。。

Fun・楽しい

  • 青:チームと一緒に仕事することがとても楽しいです。
  • 黄:悪くない。
  • 赤:つまらない。。。。

Health of Codebase・コードベースの状態

  • 青:コードの品質に自信があります。きれいで、読みやすくて、テストの網羅度も高いです。
  • 黄:リファクタリングした方が良いです。
  • 赤:コードはとても汚く、技術的負債が全く手に負えません。

Learning・学習

  • 青:いつもたくさん面白いことを学んでいます。
  • 黄:学ぶ時間が少しあります。
  • 赤:学ぶ時間が全くありません。

Mission・ミッション

  • 青:なぜ自分たちがここにいるのかを正しく理解していて、ワクワクしています。
  • 黄:聞いたことはあるが、あまり認識していません。
  • 赤:ハイレベルのビジョンや目標がなく、なぜ自分たちがここにいるのか全く分かりません。ミッションは不明確だし、モチベーションも上がりません。

Pawn or Players・駒なのかプレーヤーなのか

  • 青:自分たちの運命は自分たちでコントロールしています。何を作るのか、どのように作るのかを自分たちで決めています。
  • 黄:気にしていません。
  • 赤:私たちはチェスの駒のようなただのあやつり人形です。何を作るのか、どのように作るのかに意見を出すことはありません。

Speed・スピード

  • 青:本当に素早くやり遂げます。待つことも遅れることもありません。
  • 黄:普通
  • 赤:何も達成できていないように感じます。何度も行き詰まったり、中断したりします。依存がたくさんあるため、作業も進みません。

Suitable Process・適切なプロセス

  • 青:働き方はぴったり!
  • 黄:まあ・・・普通
  • 赤:働き方は最悪!!

Support・サポート

  • 青:必要な時にはいつでも素晴らしいサポートやヘルプが得られます。
  • 黄:たまにサポートしてもらえます。
  • 赤:助けを求めてもサポートしてもらえないので、行き詰まったままになります。

Teamwork・チームワーク

  • 青:連携がばっちり取れている楽天のスーパーチームです。
  • 黄:特に気にしていません。
  • 赤:他のチームメンバーがやっている事に興味がなくて、気にもしないメンバーの集まりです。

サードパーティーのdependencyを最新に保ち、セキュリティーとメンテナビリティーを担保しよう

ラクマの亀井です。こちらの記事ではサードパーティーのdependencyを最新に保つことの重要性とどうやって最新に保っていくべきかを記載します。

サードパーティーdependencyはすぐに古くなる

ほとんどのソフトウェアはサードパーティーdependencyを使います。それは、必要な機能をすぐに使えるようにしたり、テストをもっとかんたんにかけるようにしたり、など様々な理由でしょう。そうやって開発を続けていくと、ソフトウェアが成長しコードベースも巨大になっていくと思います。しかしながら、ソフトウェアプロジェクトは、プロジェクトメンバーが十分な時間をとれないために、ときどきサードパーティーdependencyの更新を忘れることがあります。 

古いdependencyを使い続けるとどうなるか?

すぐに何かが起こる、ということはありません。しかし、古いdependencyがセキュリティー脆弱性を抱えていると、プロジェクトはセキュリティーホールを抱えることになります。そういった脆弱性はすぐに問題にならないかもしれません。そちらはソフトウェアのインフラ、運用フロー、ソフトウェアの使用方法に依存します。もし、ソフトウェアのインフラが外部からの攻撃に耐えうる強力なprotectionを備えているのであれば、脆弱性に関連したアラートが上がることはないでしょう。

 

では、dependencyの更新無しでプロジェクトを進めることが正当化されますでしょうか?私の考えでは、そうしたプロジェクトは開発体験の低下という問題に直面するのではと思います。おそらく、最近joinしたプロジェクトメンバーはなぜ、あるdependencyのAPIが使えないのか?ということを疑問に思うかもしれません。その理由はdependencyが古すぎるゆえに新しく追加されたメソッドを使えないから、というものになります。

 

古いサードパーティーdependencyはソフトウェア開発者に古いAPIを参照することを共用してしまいます。彼らはこの状況に満足するでしょうか?私の考えではNoです。 もしかしたら、彼らのうち数名は会社自体を辞めてしまうかもしれません。

 

Dependabotとは?

DependabotはGitHubの機能の一つで、dependencyをアップデートするためのプルリクエストを自動的に作ってくれるものです。Ruby、JavaScript、Pythonなど多くのプログラミング言語がサポートされています。加えて、セキュリティーアドバイザリーをモニターして、何かしらの脆弱性が存在すればすぐに対応してプルリクエストをオープンしてくれます。

 

DependabotはrepositoryにDependabotの設定ファイルを含めることで有効化できます。設定ファイルはシンプルなYAMLファイルです。これにより、Dependabotの挙動をコントロールできます。

なぜDependabotが便利なのでしょう?

dependencyのアップデートは容易に忘れるワークフローの一つです。それに、関連するchangelogやリリースノートへのリンクをプルリクエストのディスクリプションに載せる、という細々とした操作はかなり面倒です。そもそも、誰がアップデートを行いますか?アップデートを行う、というタスクを誰かにアサインする、といったことを「毎回」やっていますか?そうしたワークフローはすぐに問題に直面します。Dependabotはそうした問題を生まずに機能するという点で非常に有益だと思います。

どのようにしてDependabotを活用するべきか?

前提として CI/CD は必須

Dependabotによるdependencyのアップデートはプルリクエストに依存します。一度プルリクエストがオープンされると多くのrepositoryではCI/CDが走り、コードの変更がproduction環境にデプロイできるものかどうかをチェックします。CI/CDによるチェックがされることによってdependency単体のアップデートを自信をもって行うことができます。

 

言い方を変えると、CI/CDがない環境ではDependabotを使うべきではありません。なぜなら、dependencyのアップデートが既存のコードベースに取り込まれて機能するかどうかを確認できないためです。そうしたプロジェクトでは、まずはテストを書きましょう。

 

Dependabotがプルリクエストをオープンしたらすぐにデプロイする

もちろん、CI/CDによる新しいバージョンのdependencyの挙動のチェックは大事ですが、いくつかのアップデートはproduction環境でしか問題を発生し得ない可能性もあります。そこで、production環境にデプロイしてすぐに挙動を確認しましょう。もし、すぐにデプロイすることに躊躇するのであれば、staging環境を使ってみましょう。staging環境は多くの場合production環境と同等の環境です。こうした環境でアプリケーションが正常に動くことが確認できれば自信をもってdependencyのアップデートができるはずです。production/stagingいずれの環境であったとしても実際に動くアプリケーションで確認することが推奨されます。

 

一度にひとつのdependencyアップデートを

Dependabotを導入すると、最初はたくさんのプルリクエストがつくられて圧倒されるかもしれません。もしかしたらそうしたプルリクエストをひとつのブランチにまとめてstaging環境などに適用したい、という欲求にかられるかもしれません。もちろん、そうした方法でも問題ありません。その場合は、まとめたブランチをデフォルトブランチにマージすることを忘れないようにしましょう。

理想的には、一度にひとつのdependencyをアップデートしたほうがいいです。なぜなら、Dependabotが一度に複数のアップデートに現状対応していないからです。加えて、複数同時アップデートは複雑なワークフローを要求するかもしれません(複数のプルリクエストを一つのブランチにマージする、それをデプロイする、確認が終わったらブランチをデフォルトブランチにマージして関連するプルリクエストを閉じる、などのワークフローがかんたんに思い浮かびます)。もし、複数同時アップデートをstaging環境とあいのりするような状況だとdependencyのアップデートだけではなく他の変更も含まれていてなにか問題が起こったときに「何が原因か」特定するのに時間がかかる、などの問題が発生するかもしれません。 

どのようにしてDependabotのプルリクエストをレビューする?

Dependabotはプルリクエストをオープンするときに以下の情報を添えます。

  • Release notes
  • Changelog
  • Commits

これらの情報はそのアップデートが適切なものかどうか判断するために重要なものです。多くのサードパーティーdependencyでは、どのような変更がそのバージョンに含まれているのかをリリースノートやchangelogを通して伝えています。そこには、新しい機能、バグフィックス、ドキュメントの更新、breaking change などが載っています。

まずは、こうした情報を注意深く読みましょう。もし新しい機能が含まれているなら自前で実装したutilityメソッドをそのライブラリーが提供する新しいものに置き換えることができるかもしれません。リファクターのチャンスですね。一方、breaking changes が含まれている場合コードベースの変更が必要になるかもしません。場合によっては"upgrading guide"のような情報を載せている場合もあります。こうした情報はdependencyのアップデートにおいてとても重要です。こうした情報を読み解いた後に「問題ない」と判断できたタイミングでアップデートのテストを行うといいでしょう。

しかし、そうしたアップデートするための必要な情報が欠けているがゆえにアップデートしていいものかどうか判断が付きかねるライブラリーがあるかもしれません。こうした場合は、そのライブラリーの commits を直接読む必要があるでしょう。しかし、リリースノートやchangelogが欠けているというのはそのサードパーティーdependencyのプロジェクトの体制に問題がある可能性があります。もちろん、commitsを読んで問題ないと判断できればいいのですが、多くの場合よくメンテされたプロジェクトほど十分なドキュメントを提供する傾向があるのは頭に入れておきましょう。

Dependabotのプルリクエストから学ぶ

Dependabotは非常に有益です。しかも、dependencyそのものから学ぶ機会も提供してくれます。いくつかのライブラリーはそれこそ魔法のような開発体験を提供し、開発者がそのライブラリーの細部まで知らなくても開発ができるようにしてくれます。Dependabotのプルリクエストはそうしたライブラリー自体について学ぶチャンスになります。どのような変更がされたのか?なぜその変更が必要なのか?を探って関連コードを読むうちに自ずとそのライブラリーのことがわかるようになってきます。また、ライブラリーのコードを読むことでその言語自体のエレガントな書き方などを学ぶことができるかもしれません。

加えて、所属するプロジェクトのドメイン知識を学ぶチャンスでもあります。dependencyのアップデートを適用する際には既存のドメインロジックを含んだコードの適用されうるかどうかを検討する必要があります。こうしたときに、アプリケーションのソースコードを読む必要がありますが、これによって、プロジェクト特有のコードに慣れるというメリットがあります。特にプロジェクトに join したばかりのメンバーにとっては有益な活動だと思います。

 

まとめ

Dependabotは多くのプロジェクトで有益です。CI/CDを有効活用することで、安全かつかんたんに dependency アップデートができるようになります。Dependabotのプルリクエストがオープンされたらリリースノートやchangelogを読んでそのアップデートが適用可能かどうかを吟味しましょう。こうした取り組みによって、ライブラリーそのものやプロジェクト特有のドメイン知識そのものの学習につながります。

業務でSwiftUIを使って画面構築してみた話

業務の中でSwiftUIを使って画面構築出来る機会がありましたので、実際に構築し終えての所感をまとめておきたいと思います。

やったこと

昨年、アプリの対応バージョンがiOS13以上となったので、SwiftUIが導入できるようになりました。 ちょうどUITableViewControllerを利用して構築されていたヘルプページの改修を予定していたので、こちらをSwiftUIへ置き換えてみました。 簡単に構成を示しておきます。

画面構成

f:id:Rakuma:20210413104232p:plain
画面構成

今回作成したViewは、Objective-Cで作成したUIKitのViewとSwiftのUIKitのViewに挟まれた場所にありました。 この構成が後に悩みのタネとなります。

クラス構成

MVPベースに作成していますが、Presenterの持つBindingされた値をSwiftUIのViewクラスへ渡すような構成となっているため、実質的にはMVVMのようになっています。

f:id:Rakuma:20210413104249p:plain
クラス構成

今回作成した画面の遷移元がUINavigationControllerを利用したViewControllerだったため、SwiftUIのNavigationViewを利用しての画面遷移実装ができませんでした(後述)。 ですので、画面遷移の実装はViewControllerで行わせつつ、レイアウト部分をSwiftUIのViewクラスで実装しています。

🥰 良い点

コードがシンプル

今回作成したViewに関連する箇所のコードのみを抽出すると、以下のようになっていました。

  • UIKit: ViewController = 60行(UI関連のみ) + Storyboard(XML)
  • SwiftUI: 75行(Preview除く)

Storyboardを使わなくなる分、SwiftUIクラス側の記述が大きくなるかと想像していましたが、2〜3割くらいコードが増えるだけで済んでいます。

作業においては、レイアウト作成中にStoryboard/Xib ⇔ Swiftファイルを行き来する必要がないため、集中しやすいと感じました。

純粋なSwiftのコードで記述できるので、複数人で同一Viewを編集するような状況になってもStoryboard/Xibのような厄介なコンフリクトが起きる心配もないですね。

Previewでレビュー効率を上げる

SwiftUIリリース時に導入されたPreviewの機能では、複数のPreviewを表示できます。 APIのレスポンスに応じて表示の切り替えを行うViewでは各StateごとのPreviewを表示することで開発効率を上げることができます。 また、レビュー時にも各Previewを参照することでレイアウトを確認できるので、モックの準備などが不要になり非常に効率的かと思います。

struct Sample_Previews: PreviewProvider {

    static var previews: some View {
        // プレビューを3つ並べる
        SampleView(presenter: successPresenter)
        SampleView(presenter: loadingPresenter)
        SampleView(presenter: failedPresenter)
    }

    // ローディング成功時のプレビュー
    static var successPresenter: SamplePresenter {
        let userInterface = SampleViewController()
        let presenter = SamplePresenter(userInterface: userInterface)
        presenter.state = .success(itemList: itemList)
        return presenter
    }

    // ローディング中のプレビュー
    static var loadingPresenter: SamplePresenter {
        let userInterface = SampleViewController()
        let presenter = SamplePresenter(userInterface: userInterface)
        presenter.state = .loading
        return presenter
    }

    // ローディング失敗時のプレビュー
    static var failedPresenter: SamplePresenter {
        let userInterface = SampleViewController()
        let presenter = SamplePresenter(userInterface: userInterface)
        presenter.state = .failed
        return presenter
    }

    // サンプル表示用のデータモデル
    static let itemList: [Item] = [
        Item(id: 1, title: "問い合わせ1")
    ]
}
表示例

f:id:Rakuma:20210413104309p:plain
Preview表示例

😵 困る点

効率的な記述のできるSwiftUIですが、一方で既存アプリへの導入時にはちょっと困ったこともあります。

UINavigationControllerとNavigationViewの互換性がない

UINavigationController&ViewControllerで構成されたViewからSwiftUIで構成されたViewへ遷移させたい場合、UINavigationControllerの内容をNavigationViewに引き継ぐことができません。

UINavigationControllerを使用したViewからNavigationViewを使用したSwiftUIのViewへ単純に遷移させてしまうと、NavigationBarが2重に表示されるような状態になります。

f:id:Rakuma:20210413104329p:plain
NavigationView

このようなケースにおいては、SwiftUIのNavigationViewとNavigationLinkを使わず、ViewControllerからUIHostingControllerを利用してSwiftUIのViewを呼び出していくのがベターかと思います。

Combine前提にしている処理が多々

例としてアラートのメソッドを挙げますが、こちらにはBinding型の引数があり、表示のコントロールを行うのにはController層、Presenter層などからBindingされた値を渡してあげる必要があります。

public func alert<Item>(item: Binding<Item?>, content: (Item) -> Alert) -> some View where Item : Identifiable
public func alert(isPresented: Binding<Bool>, content: () -> Alert) -> some View 

UIKitだとViewControllerからpresentしてあげるだけでしたが、要領が変わっているので設計上注意が必要です。

SwiftUIでは、多くのクラスでデータバインディングを前提とした構成を採用しているため、現状ではMVVMが一番スムーズに導入出来るアーキテクチャなのではないかと思います。 (ラクマアプリはMVPアーキテクチャベースとなっているため、このViewだけイレギュラーな構成となってしまいました😵)

UIKitではデフォルトで用意されている部品が一部ない

  • ActivityIndicator
  • Accessory付きのButton
  • PageControl(iOS13で非対応) etc...

UIKitに存在した上記の部品たちはSwiftUIでは用意されていないので、UIKitのクラスをSwiftUIで使えるようラップして使えるようにするか、自作する必要があります。 下記のようなLoadingViewはSwiftUI用に新規作成しています。

f:id:Rakuma:20210413104348p:plain
LoadingView

struct LoadingView: View {
    var text: String = "読み込み中..."

    var body: some View {
        ZStack {
            Color(UIColor.clear)
            VStack {
                ActivityIndicator(style: .large)
                Text(text)
                    .bold()
                    .foregroundColor(Color(ColorPalette.textWhite))
                    .multilineTextAlignment(.center)
            }.frame(minWidth: 130, idealWidth: 130, minHeight: 130, idealHeight: 130)
            .compositingGroup()
            .background(RoundedRectangle(cornerRadius: 10)
                            .fill(ColorPalette.indicatorBackground))
            .shadow(radius: 6)
            .edgesIgnoringSafeArea(.all)
        }
        .transition(AnyTransition.opacity.animation(.easeOut(duration: 0.3)))
    }
}

struct ActivityIndicator: UIViewRepresentable {

    typealias UIViewType = UIActivityIndicatorView

    var style: UIActivityIndicatorView.Style = .medium

    func makeUIView(context: Context) -> UIActivityIndicatorView {
        let indicator = UIActivityIndicatorView(style: style)
        indicator.color = .white
        return indicator
    }

    func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
        uiView.startAnimating()
    }
}

iOS13⇔iOS14間で表示差分がある

細かなところですが、iOS13とiOS14の間で表示の異なる部分がありました。下記に一例を示します。 このあたりは実装時の確認や検証をしっかり行っていく必要がありますね。

表示時のアニメーションの有無

iOS13でListを使用した画面を表示する場合、デフォルトでのアニメーションが挿入されませんでした。

iOS13 iOS14
f:id:Rakuma:20210413104406g:plain
Animation(iOS13)
f:id:Rakuma:20210413104434g:plain
Animation(iOS14)
Listのデザイン

iOS13ではListのgroupごとにカード型のような表示となっていますが、iOS14では従来のUITableViewの表示に準ずるデザインとなっています。

iOS13 iOS14
f:id:Rakuma:20210413104500p:plain
List(iOS13)
f:id:Rakuma:20210413104520p:plain
List(iOS14)

Previewが重い

XcodeでのPreview利用時にObjective-Cを利用しているライブラリのビルドが頻繁に走るため、表示の更新に分単位の時間がかかることがありました。 (このあたりはM1搭載のMacでは速くなっているのでしょうか🤔)

あとがき

既存アプリへのSwiftUIの導入にはいくつかの障壁がありますが、新規作成のViewであったり独立性の高い機能であればSwiftUIを積極的に選択肢に入れるのもアリかと思います👍 また、今後の導入に備えて既存のObjective-Cのコードを駆逐しておいたり、MVVMなどのアーキテクチャを導入しておいたりしておくことも大事かと思います。 導入のメリットも大きいですし、今後のUI構築はSwiftUIへシフトしていく可能性も高いと思いますので、積極的に使っていきたいですね。

Compositional Layoutsを使ってラクマのiOSアプリを改修した話

f:id:Rakuma:20210318101509p:plain

前書き

初めまして、ラクマのTsurutaです。

ラクマのアプリでは「保存した検索条件」の一覧が、ホームのタブから見られるようになったことをご存知でしょうか?

この画面を構成するのにあたり、ラクマではiOS12以下のサポートを行っていないため、iOSの新しいAPIである Compositional Layouts を使って構成することにしました。

実装

① iOSアプリの構成

現状ラクマのアプリは MVP をベースとした構成になっています。

UICollectionView を実装する場合、DelegateDataSource の実装が ViewController に依存してしまう問題があります。

これは Compositional Layouts を普通に実装した場合でも同じ問題に直面します。

そのため、ラクマのiOSでは以下のような構成で、疎結合な実装を実現しました。

抽象的なプロトコルとして書き出して共通化し、ViewController依存しないようにしています。

② レイアウトの抽象化

レイアウトに対して、以下のような粒度で抽象化します。

セクションに共通する処理を整理して抽象しています。 例として、抽象化するとこのようになります。

protocol SectionProtocol {
    // セクションのアイテム数
    var numberOfItems: Int { get }

    // レイアウトの生成
    func layoutSection(_ view: UIView) -> NSCollectionLayoutSection

    // セルの生成
    func configureCell(_ view: UICollectionView, 
                       at indexPath: IndexPath) -> UICollectionViewCell
}

これはただ抽象化しただけではなく、画面を構成するモデルの役割もはたします。

このプロトコルを各セクションごとに継承し、セクションごとに設定を記載していきます。

struct SectionA: SectionProtocol {

    let numberOfItems = 1

    func layoutSection(_ view: UIView) -> NSCollectionLayoutSection {
        /* 略 */
        return UICollectionViewCompositionalLayout(section: section)
    }

    func configureCell(_ view: UICollectionView, 
                       at indexPath: IndexPath) -> UICollectionViewCell {

        let cell = view.dequeueReusableCell(
            withReuseIdentifier: "your cell id", 
            for: indexPath
        ) as! SectionACell

        /* 略 */

        return cell
    }
}

各セクションを別で定義することで、ViewController からレイアウト部分を、別クラスとして分離することができます。

③ MVPでの実装

別クラスとして分離したので、ViewControllerPresenter はこのようにすっきりとした形になります。

protocol Presentable: AnyObject {
    var sections: [SectionProtocol] { get }
}

final class Presenter: Presentable {
    private var sections: [SectionProtocol]
}
final class ViewController: UIViewController {

    private(set) var presenter: Presentable!

    private lazy var collectionView: UICollectionView = {
        let collection = UICollectionView(
            frame: .zero, 
            collectionViewLayout: compositionalLayout
        )
        /* ~ 略 ~ */
        return collection
    }()

    private lazy var compositionalLayout: UICollectionViewLayout = {
        return UICollectionViewCompositionalLayout { [weak self] section, _ in
            return self?.sections[section].layoutSection(self ?? .init())
        }
    }()
}

また、抽象化した他のプロパティやメソッドは、以下のように呼び出すことができます。

extension ViewController: UICollectionViewDataSource {

    func numberOfSections(in collectionView: UICollectionView) -> Int {
        presenter.sections.count
    }

    func collectionView(
        _ collectionView: UICollectionView, 
        numberOfItemsInSection section: Int
    ) -> Int {
       presenter.sections[section].numberOfItems
    }

    func collectionView(
        _ collectionView: UICollectionView, 
        cellForItemAt indexPath: IndexPath
    ) -> UICollectionViewCell {
       presenter.sections[indexPath.section].configureCell(collectionView,
                                                           at: indexPath)
    }
}

抽象化されているため、呼び出しが集約されてとても綺麗な実装になっています。

詳しい実装は以下に掲載してありますので、興味があればご覧になっていただけると幸いです。

qiita.com

github.com

Compositional Layouts の不具合

今回の実装において苦労したこともあり、Compositional Layouts でしか発生しない不具合に遭遇しました。

その1つとして、

  • iPad

  • 特定の条件下

  • 画面回転

この時に、アイテムが全て消えてしまうという現象がありました。

既存の UICollectionView のレイアウト実装では発生せず、検索しても出てこないため、対応方針をどうしようか悩みました。

結論としては、画面回転時に画面をリセットしてから再描画することで対応することにしました。

ただ再描画するだけでは解決せず、リセットする(UICollectionView が参照している Section 全てを空にしてリロードする)作業を行うことが、この問題を解決することのポイントになります。

ただし、この解決方法は、再描画するため回転前のポジションを維持できないという弊害もあります。内部的にポジションを保持して引き継ぐことも可能ですが、特定の条件下ということもあり、対応コストとメンテナンス性を考えて、ポジションの維持は行わない決断となりました。

終わりに

徐々に導入しているプロジェクトが増えてはきているものの、まだそんなに母数が多くないため、Compositional Layouts のベストプラクティスな構成を見つけるのは難しいかもしれません。

また、SwiftUIも台頭してきており、今後のことを見据えて疎結合な実装で分離しておくことは、プロジェクトにおいてメンテナンス性の高い実装になると思います。

その他

ラクマでは、User Firstをコア・バリューの一つに掲げ、一緒にアプリ開発をしてくれるメンバーを募集しています。

www.wantedly.com

www.wantedly.com

www.wantedly.com

カジュアルな面談から、是非お待ちしております!