こんにちは、クライアントエンジニアの竹澤(@to4iki)です。
Mirrativでは、リアルタイムの配信視聴時のコメントやギフト機能を実現するために、WebSocket ベースの仕組みを利用して、サーバー・クライアント間でPub/Subのメッセージングを行っています。*1
サーバーからのHTTPレスポンスをマッピングするように、ペイロードのJSONを受け取り、それをクライアントで扱う型に変換して使用していますが扱いにくい点がありました。本記事では改善に取り組んだ内容と進め方を紹介します。
目次
- 課題
- ゴールと進め方の認識あわせ
- 1. 最初にゴールの定義を行う
- 2. 段階的な進め方の方針と見通しを立てる
- 3. 技術的な懸念事項などを先に潰し作業をパターン化する
- まとめ
- We are hiring!
課題
Mirrativのリアルタイム通信の実装は古くから存在し、歴史的経緯により返却される値の型が不安定であったり、どのようなレスポンスが返ってくるかの情報が一部アンドキュメントとなっていました。
また、ペイロードのJSONには識別情報の BCType
が存在し、種別に応じたメッセージを受信するような挙動になりますが、既存実装が以下のような構造となっていたためプログラム全体の見通しが悪くメンテナンス性が低い状態でした。*2
- 神クラスに全種別のレスポンス定義が集約されている
BCType
毎のメッセージに対応したレスポンスクラスが存在するのではなく一意な構造として定義されている。複数のBCType
のプロパティをデコード可能とするため、全プロパティがOptional
もしくは デフォルト値付きで定義されているため、プログラム内で利用する際にどの種別の情報がセットされているのか静的に判断がつかない
- コードベースの多岐にわたって巨大な神クラスを引き回しているため、内部プロパティの追加や振る舞いを調整した際の影響範囲が広く予測が難しい
旧構造の疑似コード
/// HTTPリクエストに例えると、エンドポイントに相当する一意性を担保する情報 public enum BCType: Int { /// 視聴者が配信に入室したときに流れる case joined = 1 /// 視聴者がギフトを送った時に配信者に流れる case gift = 2 ... /// 配信が終了したら流れる case ended = 100 }
/// 種別に関係なく全レスポンス(メッセージ)を保持する汎用クラス /// 全てのプロパティが `Option<T>` もしくはデフォルト値付き。SwiftyJSONを利用しデコードしている public class BCResponse { /// BCType.gift でのみ受信するプロパティ public let giftId: String? public let giftTitle: String? public let coins: Int? /// BCType.Xxx で共通で返却されるプロパティ public let userId: String? public let name: String = "" public init(json: SwiftyJSON.JSON) { self.giftId = json["gift_id"].string ... } }
/// BCType.gift のメッセージを受け取った際の後続処理 /// 巨大な `response: BCResponse` を引き回している func handleGift(response: BCResponse) { giftView.configure(response: response) } final class GiftView: UIView { func configure(response: BCResponse) { // `BCType.gift` でセットされている情報をプログラマが知っている必要があり暗黙的 if let giftTitle = response.giftTitle { titleLabel.text = giftTitle } if let coins = response.coins { coinsLabel.text = "coins: \(coins)" } ... } }
既存の構造を否定したいわけではなく、プロダクトの立ち上げフェーズにおいては種別が限られていたこともあり1つのクラスに集約することで実装を再利用できたりと利点が大きかったのですが、サービスの進化に伴い調整が追いつかず拡張に弱い構造となっていました。
ゴールと進め方の認識あわせ
現状の課題は言語化できたので、次に改修後の理想状態と進め方の認識を揃えます。
iOSチームでは機能開発やユーザー問い合わせ対応と並行しリファクタリングを行っています。 チームとして目線を揃え注力する開発改善の目標を四半期ごとに立てているので、取り組みを明文化し立ち向かうことにしました。 *3
以降の章で進め方に関して順に説明していきます。
1. 最初にゴールの定義を行う
手を動かす前に影響範囲の調査を踏まえゴールとする構造をissueに示します。
神クラスの解体は一見して分かりやすいゴールではありますが、本対応のスコープでは種別毎の構造に解体し呼び出し側の依存関係を最小化するのと脱SwiftyJSONをゴールとしました。
種別毎のレスポンスクラスに切り出し
public enum BCResponse { case joined(BCResponse.Joined) case gift(BCResponse.Gift) ... case liveEnded } extension BCResponse { /// BCType.Gift 専用のレスポンスクラス /// プロパティは非Optional public struct Gift: Decodable { public let giftId: String public let coins: Int } }
呼び出し側では利用する種別に特化した型を依存に指定する
func handleGift(response: BCResponse.Gift) { giftView.configure(response: response) }
2. 段階的な進め方の方針と見通しを立てる
ゴールは確定したので次にどのように手を動かすか戦略を考えます。
影響範囲が広い神クラスはプロジェクト全体で利用されているため、通常の機能開発に影響を与えないように並行しての改善となります。最終的な調整には手間がかかりますが、影響のスコープを限定し、部分的に置き換えることで単体テストや開発者間の動作検証を通じて機能が壊れていないことを保証することを優先しました。そのため、末端の利用箇所から段階的に置き換えていく方針を取りました。
進め方に関してもゴールの明文化と同様に具体的な作業をissueに書き起こします。
ゴールとするenumの構造に置き換えるのは最終調整とし、旧実装のプロパティはdeprecatedとして残したまま、新規実装から構造化したレスポンスを参照する
public struct BCResponse { /// BCType.gift でのみ受信するプロパティ /// deprecated public let giftId: String? public let giftTitle: String? public let coins: Int? // 個別のプロパティと被るが新規実装から解体後のプロパティを利用、段階的に個別のプロパティ参照からこちらに置き換えていく let gift: BCResponse.Gift }
解体とセットで、comment: Comment
のような引数の依存を新構造に置き換える
func handleGift(response: BCResponse) { guard let gift = response.gift else { return } giftView.configure(response: gift) }
ゴールまでの道筋を可視化する
施策実装の隙間時間を利用して、比較的時間のかかるリファクタリング作業を行うため、進捗の可視化と分担を行いやすくするため、調整対象となる BCType
の一覧を明示します。
3. 技術的な懸念事項などを先に潰し作業をパターン化する
次に予測可能な技術的課題の解消や、ローカル環境でのデバッグを楽に進めるための調整を行います。
動作検証に利用する再利用可能なサンプルのペイロードを定義する
Pub/SubライブラリのメッセージはAPIリクエストのように能動的に実行しレスポンスを受け取ることはできないため、MirrativのAndroidアプリ開発ではメッセージを再現するためFlipperを活用しています。 カスタムのFlipperプラグイン自体はすでに存在していたので、神クラス解体のタイミングでiOSにもFlipperを導入することにしました。
Flipperの導入が完了後、解体後の構造に実装を置き換えた場合の動作検証を簡単に行うことが出来るように再利用可能なペイロードのJSONをUnitTest内に定義することにします。
final class BCResponseTests: XCTestCase { func testGift_t2() { /// 検証時にそのまま利用可能なペイロードのJSON let json = """ "t": 2, "gift_id": "12345", "coins": 100, """ let actual = BCResponse.Gift(json: json) let expected = BCResponse.Gift(... XCTAssertEqual(expected, actual) } }
Decodable準拠のレスポンスでInt or Stringを解釈可能とする
既存実装では、以下のような文字列or数値で返ってくるようなJSON-valueをプロジェクト内で扱うために一意の型にマッピングする実装が存在しました。
{ "gift_id": "12345" or 12345 }
具体的には、SwiftyJSON側で定義しているなnon-optionalなデコード実装を利用しています。 github.com
public struct BCResponse { public let giftId: String? init(json: SwiftyJSON.JSON) { self.giftId = json["gift_id"].stringValue } }
ゴール設定に示した通り、神クラス解体に伴い、SwiftyJSONを利用した手動デコードを撤廃し標準APIの Decodable
を活用することにしました。
ただし、init(from decoder: any Decoder) throws
内で String 型としてデコードを試み、失敗した場合のフォールバック実装としてInt型でデコードするような実装を毎回記述するのは手間だったので、複数型をデコード可能とする PropertyWrapper
を作成し利用することにしました。
実装の一部を切り出したライブラリ: github.com
SwiftyJSON依存を無くし Decodable
に準拠した置き換え後の定義はこのようになります。
public struct BCResponse: Decodable { @ForcibleString public var giftId: String enum CodingKeys: String, CodingKey { case giftId = "gift_id" } }
リファレンスとなるPRを用意する
ここまでで進め方が固まり技術的な懸念事項も潰すことができたので、リファレンスとなる 1st PR を作成し非同期のコードレビューだけではなく、口頭でも内容を説明します。
リファレンスとなるPRを用意することで、以降の解体作業は誰でも明確な手順に従って効率的に進めることが可能になりました。
まとめ
本記事ではサービスローンチ当初から存在する巨大な神クラスの解体に立ち向かった際の作業内容と方針を紹介しました。
長期間運用しているサービスに関して、リリース当初から存在し新規実装時の障害となる実装が存在することがあると思いますが、何が課題でそれを解決した際に将来どのような嬉しいことがあるのか、ゴール設定と取り組むにあたってのアクションプランを示しながら今後も改善に取り組んでいきたいと思います!
We are hiring!
ミラティブではユーザーにより価値を届けるためにチームを巻き込んで共に成長していける方を募集中です!
*1:https://speakerdeck.com/mirrativ/engineers-handbook?slide=19
*2:2023/6月現在で扱っている種別は約60個ほど存在します