iOSアプリの画面遷移図を自動生成する

RNIアドベントカレンダー 3日目の記事です。

こんにちは。リサーチ・アンド・イノベーションの小川です。 iOSエンジニアとしてCODEアプリの開発を担当しています。

CODE iOSチームの2024年の取り組みの一つとして画面遷移図を自動生成することを行いましたので、そちらの共有をしたいと思います。

モチベーション

  • 画面遷移の把握を簡単にしたいので画面遷移図は作りたい
  • 手動で作るのは手間が発生するので自動で作りたい
  • 画面遷移図を手動でメンテナンスせずに最新に保ちたい
  • 画面の表示に影響のある改修が入った際は差分検知したい
  • スクリーンイベント名と実際の画面の対応をビジネスサイドに共有したい
続きを読む

2024 RNIアドベントカレンダー1日目 iOS版CODEで使えそうなAppleの技術を振り返る

リサーチ・アンド・イノベーションの中島です。
RNIアドベントカレンダー 1日目の記事です。

Appleは毎年WWDCで新しい技術の発表を行うのが通例ですが、あまり流行らずに忘れられていく技術も多々あります。 この記事ではいくつかの技術を振り返ろうと思います。


目次:

  • VisionKit Data Scanner
  • App Clip
  • おまけ

続きを読む

RNIアドベントカレンダー2024

2024年、今年もRNIアドベントカレンダーを開催します!

日付 名 前 所属 内容
12/18 中島 iOSエンジニア rni-dev.hatenablog.com
12/19 武田 Androidエンジニア qiita.com
12/20 小川 iOSエンジニア 画面遷移図自動生成
12/21 ホン iOSエンジニア TCAの@Sharedを使ってStateを共有する
12/22 S サーバサイドエンジニア AWS Network Proxy を構築した話
12/23 横山 サーバサイドエンジニア Rubyの何か(アップグレード関係の予定)
12/24 中島 iOSエンジニア 社内デスクツアー
続きを読む

RNIアドベントカレンダー7日目 Lambda のアップデートをした話

これは RNI 開発部 (SDD) Advent Calendar 2023 最終日の記事です。

本来は 2 日目に入る予定だったのですが、諸事情で最終日になってしまいました。 来年は早めに書いておこうと思います。

Lambda のアップデートをした話

SDD では 3 年ほど前に主要なインフラを AWS に移行しました。 その後、徐々に Lambda 上のスクリプトが増え、現在 RnI で利用しているのは以下言語になっています。

RnI では今年、特に利用している Lambda ランタイムのサポート終了が多く、 3 年前から使っていた多くのスクリプトがアップデートの対象になりました。

参考: Lambda ランタイム - AWS

実際には、以下の通りアップデートを行っています。

  • Ruby 2.7 -> 3.2
  • Go 1.17 -> 1.20
  • Node.js 14.x -> 18.x
  • Python 3.7 -> 3.11

テストコードがない Lambda をアップデートする

RnI では基本的に TDD で開発を進めているため、コアである Rails や iOS, Android のコードにはテストコードを書いているのですが、Lambda のコードは AWS への移行期にコードを書いていたこともあり、多くのコードがテストコードを書いていない状態で放置されていました。

今回は時間の制約があり、新たにテストを書く時間が取れなかったことから、以下のように方針を定めました。

  • Linter & Formatter を(VSCode 上でのみ)導入する。
  • 実行時にエラーが出ないように、アップデートで起きる問題を(特に Linter で)事前に解決する。
  • 依存するライブラリはリリースログを確認しながら破壊的な変更がないことを確認し、アップデート後に問題が起きる可能性を低くしておく。

上記の方針を定めた理由として、Lambda 上で動くコードが外部ライブラリをほぼ利用していなかったこと、個別のスクリプトが大きくなかった(100 行未満がほとんど)ため、ソースコードを読むだけで挙動を確認しやすかったことが挙げられます。

Linter の適用で防げた問題

Linter の適用により最も効果があったのは言語レベルでの変更点でした。 わかりやすい例として、以下のようなものがあります。

Python 3.9 で削除された dateutil の可視化

dateutil は Python 3.9 以降、 python-dateutil に名称変更されています。 RnI では今回、 Python 3.7 から 3.11 へのアップデートだったため、この問題に対処する必要がありました。 こういった点に対しては、VSCode 上で依存ライブラリの名前解決ができない、といった表示がされるため非常にスムーズに対処していくことができました。

なお、 dateutil についてはタイムゾーンを扱うために導入していたことから、今回は名称変更ではなく、 zoneinfo を使う形で修正を加えています。

3.7 時点でのコード:

from dateutil import tz, zoneinfo
datetime
    .fromtimestamp(int(j['timestamp']) / 1000)
    .replace(tzinfo=tz.tzutc())
    .astimezone(zoneinfo.gettz("Asia/Tokyo"))
    .strftime('%Y-%m-%d %H:%M:%S')

3.11 用に修正したコード:

from zoneinfo import ZoneInfo
datetime
    .fromtimestamp(int(j['timestamp']) / 1000, tz=ZoneInfo('UTC'))
    .astimezone(ZoneInfo("Asia/Tokyo"))
    .strftime('%Y-%m-%d %H:%M:%S')

requirements.txt や Gemfile , package.json などの設置

Lambda デプロイ後に特に問題が発生せずに運用されたスクリプトの中には、ライブラリのバージョン指定が明確にされていない(requirements.txt などが存在しない)ものが少数ながらも存在しました。

これらについては、正確には Linter で防げたわけではないのですが、上記 dateutil の問題を修正する際に併せて発覚したため、標準ライブラリ以外を利用している Lambda スクリプトに対してはバージョン指定を明確に行う修正を行いました。

Linter の導入で防げなかった問題

ここは計画時点での盲点でもあったのですが、RnI では CodeBuild を利用して Lambda スクリプトのビルドを行っています。 そのため、各言語のバージョンアップに伴い、 buildspec ファイルの runtime-versions セクションを書き換えるだけでなく、使用可能なランタイムを確認してアップデートする必要がありました。

参考: 使用可能なランタイム - AWS

複数の言語やバージョンを扱っている buildspec ファイルの分離

RnI では上述の通り複数の言語を使用しているのですが、これら複数の Lambda スクリプトを 1 つの buildspec ファイルで扱っていたため、 CodeBuild 側の Linux イメージのバージョンを合わせることができず、複数の buildspec ファイルに分離する必要が生じました。

x86_64 から arm64 への移行

これは Go でのみ発生した問題ですが、RnI では Mac を使って開発をしているため、ここ数年でローカル開発環境が x86_64 から arm64 になっています。 ローカル環境でビルドのテストを実施する際には arm64 になるため、併せて Lambda 側のランタイム設定でも同様にアーキテクチャを x86_64 から arm64 へ変更しました。

しかしながら、これまで Lambda スクリプトは全て x86_64 で動作させていたため、この項目は除外(デフォルト値の通りに設定)しており、 CFn の設定を変更しておらず、デプロイ時に一時的に障害が発生する原因となりました。

本来、 x86_64 から arm64 へのアーキテクチャ変更時には、 CFn ファイルへ以下を追加する必要があります。

参考: AWS::Lambda::Function - AWS CloudFormation User Guide

Resources:
  LambdaResourceName:
    Type: "AWS::Lambda::Function"
    Properties:
      Architectures:
        - arm64

今後 RnI では arm64 で動作させるリソースが増えていくことが想定されるため、必要に応じて追記・修正していく必要があります。

今後対応していくこと

今回のアップデートでは、個々の Lambda スクリプトで利用している外部ライブラリがほとんどなかったことと、スクリプト自体の行数もさほど長くなかったため力業で進めてしまいましたが、今後 Lambda への依存度が高くなってくるとこの方法では難しい事が容易に想像できます。

幸いながら、今回は 1 スクリプトのみ問題が発生する比較的軽微なダメージでアップデートが完了していますが、今後もこの方法で進めていくのは憚られます。 今回のアップデートでは、今後の対応方針を見極めるためにも作業ログを残し、どのような点で躓いたかなどを併記しておくことで、以下の課題及び今後対応していくべきことが改めて確認できました。

Lambda スクリプトもきちんとテストを書いておく

個々のスクリプト自体はさほど難しくない内容であっても、同時に多くの Lambda がアップデート対象になるとそれなりに確認に時間がかかります。 AWS に移行した当初は Lambda スクリプトの数もさほど多くなかったため、手作業で 1 つ 1 つ見ていく方法でも運用上大きな問題はありませんでした。ですが、この 1 年で Lambda スクリプトの数はかなり増えており、今後も増えることが容易に想像できます。

また、外部ライブラリ(pip や RubyGems など)を利用しているコードは、ライブラリ側のアップデートも併せて確認していく必要がある他、アップデートのタイミングで言語レベルで名称が変更されているモジュールなどを確認する必要があることから、Rails 同様に、それぞれの言語に適したテストコードを記載し、CI の段階で問題がないことを担保することが必要です。

Lambda にもローカル環境を用意する

本番環境上で動作させている Lambda を一発で修正するのはそれなりに難しい作業になります。しかし、現時点では Lambda スクリプトが layered になっている部分があったり、依存する AWS 上の別サービスがあることから、ローカル環境でスクリプトを実行してテストを行うことが今回はできませんでした。 また、本番環境上でテストを行う際にも、他のシステムに影響が出るスクリプトもあり、実際にスクリプトを動かしてテストできる Lambda スクリプトは限定的になっていました。

従って、今後は LocalStack などを併用し、ローカル環境でインフラ側もある程度テストできる環境を整えるとともに、個々の Lambda スクリプトをより管理しやすい AWS SAM や Serverless Framework の導入を行うことで、実際の AWS 上でも本番同様に準備された複数の環境を利用できるようにしていくことが必要です。

SAM や Serverless Framework には、モックパラメータを使ってテストを回す仕組みも備わっており、モックパラメータがわかっている状態でテスト実行ができるだけでも、アップデート対応時にはかなり助かります。

Lambda のデプロイは自前でコードを書かず、ツールに任せる

AWS に移行した段階で用意した Lambda のデプロイ用コードは、 CodeBuild でビルドしたコードを S3 にコピーし、一括で全 Lambda スクリプトをデプロイする形式になっていました。そのため、1 ファイルの修正であっても、都度全ての Lambda スクリプトがビルド & デプロイされてしまいます。

Lambda スクリプトの数が少ない時にはこの方法でも充分だったのですが、今回のように複数のファイルを随時にアップデートしていきたい場合などには、ビルド & デプロイにかかる時間が積もっていきます。

自前のスクリプトを修正することでも対応は可能ですが、既にあるツールを使わないのも DRY ではないため、 Lambda のデプロイは自前でコードを書かずに Serverless Framework, AWS Serverless Application Model (AWS SAM) のようなツールに任せて行くのが良いでしょう。

特に今回、 CFn ファイルが 1 つにまとまっているメリットがデメリットを上回ってしまう形になったため、AWS SAM は CFn ファイルが増えてしまう問題はあるものの、個別に更新可能なメリットも享受できる形式であると感じました。

まとめ

Lambda スクリプトは気軽に作成することができ非常に便利ですが、アップデートやテストなど、下回りが疎かになっていました。

RnI ではコアの部分で利用している Rails や iOS, Android のスクリプトはきちんとテストコードが書かれ、CI/CD が確立されています。 今回 Lambda のアップデートで躓いた部分も、「テストを書きましょう」「CI/CD をきちんと回しましょう」「DRY に作りましょう」といった、よく言われるものの蔑ろにしてしまいがちな部分をおざなりにしてしまったために起きた事故と捉えることもできます。

継続的な開発をしていく上で重要なこれらの要素を、 Lambda アップデートの中で再確認できたのは非常に有意義だったと感じています。また、2024 年にこれらの問題を一緒に解決していけるエンジニアを RnI では引き続き採用しています。興味がある方は是非一度、RnI の採用ページをご覧いただけると幸いです。

Happy holiday!

RNIアドベントカレンダー6日目 2024年に使いたい技術

2024年に使いたい技術

メリークリスマス! リサーチ・アンド・イノベーションの横山です。

RNIアドベントカレンダーの6日目になります。

今年も残り一週間ということで、来年2024年に使いたい技術についてお話しようと思います。

ruby3.3

明日はいよいよクリスマス。例年、rubyは12月25日に新しいバージョンがリリースされます。

恐らく明日にはruby3.3がリリースされることでしょう。 既にRCがリリースされており、何事もなければこのまま正式版がリリースされると思います。

ruby3.3の新機能としてはパーサの置き換え、RJIT(rubyで書かれたJITコンパイラ)の追加、irbとrdbg(デバッガ)の連携強化などがあります。 現在ruby3.2へのアップグレードで苦戦している状態であり、まず解決しなければならない問題がいくつかありますが、ここを乗り越えれば3.2->3.3のアップグレードはさほど難しくないはずなので、頑張ろうと思います。

debug gem

ruby3.xで導入されたデバッグ用のgemです。

アップグレード作業で既に使っていますが、本格的にruby3.2(3.3)に移行すればdebug gem(rdbg)でデバッグが捗るはず。 現時点はターミナルと組み合わせていますが、後述のdevcontainerと組み合わせれば、vscode上で完結するようにもなります。

dev container

vscode上で使うことのできる、dockerを使った開発環境(ローカルサーバ)です。

ずっと秘伝のタレと化したdocker composeを使って開発してきたこともあり、dev containerへの移行をやっていませんでしたが、ruby3.2(3.3)への移行を機にdev container化を図ろうと思います。 昨年あたりまではエディタもまちまちだったので踏み切れませんでしたが、さすがにもうみんなvscodeなので・・・(かく言う私も昨年ごろemacsからvscodeに切り替えました)

TiDB

最近注目されている、mysq互換かつスケーラビリティの高いデータベースエンジンです。

ちょうどmysql8への移行でデータベース周りの見直しが進んでいますが、事例を見るとなかなか良さそうなので是非試してみたいと思っています。

RBS

rubyの静的型解析(型情報ファイル)です。

型なんて要らない、と思っていたんですがirbやvscode上でのメソッド補完が効くようになって便利らしい(rubykaigiでめちゃくちゃアピールされた)ので来年は手を出してみようと思います。

passkey/FIDO2/webauthn

公開鍵認証を用いたパスワードレスの認証方式です。

長年クラシカルな認証方式を使ってきましたが、今年はユーザの増加に伴い不正アクセスの試みを受けるなど、セキュリティ上の懸念点が出てきた年でもありました。 不正アクセスのほとんどはメールアドレスとパスワードの使い回しによるものです。 メールアドレスを用いた従来のオンラインサインアップはパスワードの使い回しをする人が多く、どこかのサービスからメールアドレスとパスワードの組が漏れると芋づる式にやられてしまう問題を秘めています。 以前はSNSログインを推奨してきましたが、TwitterのAPI有料化騒動でSNSログインも難しくなってきており、一方で今や生体認証のない端末の方が珍しくなってきているため、今後はpasskeyによる認証が解決の糸口になると思っています。

Idempotency-Key Header

ヘッダに一意のキーを持たせることで多重POSTを防ぐ仕組みです。

同じリソースに対するPOSTリクエストが複数回来た場合に多重に更新されることを防げます。 今までこの問題に対する対策はクライアント側の実装によるしかなく、特にネットワークの切断を考慮しなければならないスマホアプリでは設計の複雑化を招いていましたが、この技術でシンプルにすることができるでしょう。 まだドラフトの段階だと思いますが、実装はそれほど難しくないと思います。

自作キーボード関係

今年は2つ(+マクロパッド)作りました。

来年も何か1つは作りたいなぁ。無線化も試してみたい。

rubykaigi2024@那覇

技術ではないですが、来年も必ず行きます。

数年ぶりの沖縄も楽しみです。 毎年参加していますが、今年のkaigiは一人ではなく社内からも数名参加した人がいて、現地で交流したりもできました。 参加した人からは会社で何かしたいという要望も出ているので、一緒に検討していきたいと思います。

それでは皆さん、良いクリスマスを。

RNIアドベントカレンダー2023 4日目 iOS TCAへの移行を行ってみた感想と既存との調整点

RNIアドベントカレンダー2023 4日目

こんにちは。リサーチ・アンド・イノベーションの小川です。 iOSエンジニアとしてCODEアプリの開発を担当しています。



CODE iOSチームの2023年の一番大きかったトピックとしてはTCAへの移行でした。

TCAとは

公式リポジトリ

ここ近年どんどんメジャーになってきているアーキテクチャ。

The Composable Architecture の略。pointfree.coが開発。

Reduxとかなり近い構成です。

スマホアプリは画面の生存期間が長く、Webアプリと同等以上にRedux的アーキテクチャの恩恵を受けられると思ったので採用の候補にしました。

移行してみて感じたこと



公式が掲げる 「開発者にとっての使いやすさ」というのを非常に実感しました。

色々利点は感じたのですが特に感じたのは、TCAであれば全ての画面やコンポーネントを同じ形で表現できるということのメリットです。
これにより「驚き最小の原則」が守られると感じました。


また、インターフェースに一貫性があることでコンポーネントを分業で実装することが容易になりました。

実装する際の理解

実装を始めて当初はなかなかその記述の意味が分からず、コピーして試行錯誤していました。

慣れてきた今、以下は改めて認識した事項となっています。

各コンポーネントの実装で集中すれば良い点

  • そのコンポーネントが持つ状態を把握し記述する
  • アクションによって状態がどのように変わるかを記述する
    • もし自分の状態が変わらないのであれば何もしないことを記述すれば良い

コンポーネントを結合する際の前提

  • 親コンポーネントが子供のコンポーネントの状態データを持っている
  • 子供自身が自分の状態を変更した際、親はそのデータを持っているので変化に追従できる
  • 子供のコンポーネント上でのユーザアクションを親が利用できる

結合の際、親が子のアクションを利用する部分をEnumで網羅的に記述できるのでどのように連携しているのかの把握もしやすいです。

TCAではEnumを多用しており、associated valueの中身にさらにEnumを入れ子にする記述で親と子の関連をシンプルな記述で実現しています。

子コンポーネントが状態を持たないシンプルな例

以下のようなカウンターで考えます。

子コンポーネントの実装方針は以下になります。

  • 状態
    • なし
  • アクション
    • + ボタン押下
      • 何か変化することはない(ただ実行されることを書くだけ)
    • - ボタン押下
      • 何か変化することはない(ただ実行されることを書くだけ)

具体的には以下のように非常にシンプルなものになります。

public struct ChildViewReducer: Reducer {
    public struct State: Equatable { // 状態はなし

    }

    public enum Action: Equatable { // アクションの定義
        case plus
        case minus
    }

    public var body: some Reducer<State, Action> { // アクションによって状態がどう変化するか
        Reduce { state, action in
            switch action {
            case .plus, .minus: 
                return .none // このコンポーネントの状態は何も変化しない
            }
        }
    }
}

View側の実装は以下です。+ , - ボタンが存在し、アクションを送信するのみです。

public struct ChildView: View {
    public let store: StoreOf<ChildViewReducer>
    public var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            HStack {
                Button("+") {
                    viewStore.send(.plus)
                }
                Button("-") {
                    viewStore.send(.minus)
                }
            }
        }
    }
}

親コンポーネントの実装は以下になります。

  • 状態

    • カウントの数字
    • 子供用の状態
  • アクション

    • 子供のアクションを捕捉する
      • + 押下
        • カウントを1増やす
      • - 押下
        • カウントを1減らす

親のアクション定義で子供のアクションを利用する際は case xxx(子供のアクション) と宣言できます。

以下の例では child という名前のアクションに子供のアクションをマッピングしています。

public struct ParentViewReducer: Reducer {
    public struct State: Equatable { // 状態はカウントの数字, 子供用の状態を持つ
        public var count = 0
        public var childState = ChildViewReducer.State()
    }

    public enum Action: Equatable {
        case child(ChildViewReducer.Action) // 子供のアクションを中身とするアクションを定義
    }

    public var body: some Reducer<State, Action> { // 子供のアクションを利用して自分のカウント状態を変更
        Reduce { state, action in
            switch action {
            case .child(.plus):
                state.count += 1
                return .none
            case .child(.minus):
                state.count -= 1
                return .none
            }
        }
    }
}

Viewは以下となります。

子コンポーネントを使用する際に scope にて自分が持つ 子供用の状態 と 子供のアクションを中身にとるアクション を指定します。

public struct ParentView: View {
    public let store: StoreOf<ParentViewReducer>
    public var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                Text(String(viewStore.count))
                ChildView(store: store.scope(state: \.childState, action: ParentViewReducer.Action.child))
            }
        }
    }
}

このように実装しますが、各々のコンポーネントを一貫したルールで連結できるのがとてもよかったです。

あと、外界とのやり取りはすべてDependencyとして定義する点も一貫性があって理解しやすく、モックデータの用意の意識も必ずすることになるので動作検証もスムーズに行えました。




既存実装との調整について


リプレースにあたり、なかなか全てをいきなり入れ替えるのは難しかったので一部分をTCAに置き換え、徐々にその領域を広げていく方針を取りました。



非TCA実装からTCAデータの更新を行う場合

データ更新のタイミングでNotificationを送信し、それをTCAのrootのViewが受け取ってReducerのActionを呼び出すことで実現しました。

例えば以下のような形です。

.onReceive(NotificationCenter.default.publisher(for: Notification.Name(rawValue: Constants.NotificationName.updateTCA))) { _ in
    viewStore.send(.updateFromOldData)
}

既存画面(UIKit)からのTCA画面呼び出し

こちらはSwiftUI Viewと同様UIHostingControllerで実現しました。

let view = TCAView(store: Store(initialState: .init()) {
    TCAViewReducer()
})
let hostingVc = UIHostingController(rootView: view)

途中にUIKitが挟まる場合

遷移上、途中のステップに既存画面(UIKit)が挟まる場合と挟まらない場合が生じました。

  • パターン1: TCA画面(A) -> TCA画面(B)
  • パターン2: TCA画面(A) -> UIKit画面(X) -> TCA画面(B)

このパターン2についてですが、UIKitの画面(X)に(B)のStoreをpropertyとして持たせ、(A)から(X)に遷移する際に(A)->(B)にscopeしたStoreを持たせることで実現しました。

ちょっと言葉だと分かりづらいですが、以下な感じです。

// AからXの遷移部分のコード
let xViewController = ... 
xViewController.bStore = store.scope(state: \.b, action: AReducer.Action.b)
// xViewController を UIWindowSceneから取得したViewControllerから表示する

// XからBの遷移部分のコード
let view = BView(store: bStore)
let hostingVc = UIHostingController(rootView: view)
self.navigationController?.pushViewController(hostingVc, animated: true)

まとめ

TCAリプレースにより、開発体験がかなり改善されました。

まだ移行完了しているのは一部なので、引き続きTCAへのリプレースを継続しています。


リサーチ・アンド・イノベーションではエンジニアを募集中です。

興味を持っていただけた方はお気軽にご連絡ください!

採用情報 r-n-i.jp

RNIアドベントカレンダー1日目 railsアップグレードとのび犬問題

リサーチ・アンド・イノベーションの横山です。 アウトプットをさぼってさぼってもう5年ほどになりますが、うっかり「うちもアドベントカレンダーってやらないんですか?」と口走ってしまったため言い出しっぺの法則で記事を書くことになりました。 しばらくの間お付き合いください。

自己紹介

10年以上流しのエンジニアをしております。 いろいろな会社で働いてきましたが、ここRNIはお酒好きな人が多いのでやりたいことをやらせて貰えるのでなかなか居心地が良く、気が付けば5年ほどお世話になっています。 Rails2.0の頃からrailsで仕事をしており、趣味で書いていた頃も含めるとruby歴は20年以上になるでしょうか。 ハチドリ本も持っています。ハチドリだったの知ったのは最近ですけど。

プログラミング言語Ruby(2009)

railsアップグレードへの長い道のり

さて、稼働中のサービスあるあるですが、弊社のサービスも一部古いバージョンのrubyやrailsで動いています。 アップグレードしたいと思いつつ、最近まで人手不足で手が出せませんでしたが、余裕ができたので今年大きく動き始めました。 最も懸念となっているのがrails5.1で動いているサービスです。 ruby2.xがEOLとなったので(なる前からアップグレードは始めていたのですが)一刻も早くアップグレードしなくてはなりません。 中断を挟みつつ1年近くかけてようやく終わりが見えてきましたが、途中でぶつかった最も大きな壁について書いてみます。

背景

問題のサービスは(歴史的経緯から)別のサービスと密に結合しており、同じRDBMS上に2つのデータベースを置いて相互に読み書きできるようにしています。 以後はこれをサービスAlpha、サービスBravoとします。 AlphaのActiveRecordのクラスは素直に実装されており、基底クラスでデータベース名としてbravoを付加し、テーブル名にalphaを付加するようにしている以外、特に意識せず利用できるようになっています。

class Bravo < ApplicationRecord
  self.abstract_class = true
  establish_connection :"bravo_#{Rails.env}"

  class << self
    private

    def database_name
      Rails.configuration.database_configuration["bravo_#{Rails.env}"]['database']
    end
  end
end

class Company < Bravo
  self.table_name = "#{database_name}.alpha_company"
  ...
end

BravoからAlphaのクラスを使う時は、NamespaceにAlpha::を付加しています。

module Alpha
  def self.table_name_prefix
    'alpha_'
  end
end

class Alpha::Company < ActiveRecord::Base
  ...
end

これで名前空間を分けた状態で共有するモデルを扱えます。

polymorphic関連

この構造でbelongs_toやhas_manyなどrailsの関連はすべて透過的に使えるのですが、polymorphic関連だけ問題があります。 おさらいになりますが、polymorphic関連とは関連付けられるモデルのクラスを限定せず、規定のカラムを用意することで関連先のモデルを限定せずに関連付けられる仕組みのことです。 例えばMemberというクラスがCompanyまたはPartnerクラスに関連付けられる仕組み場合は以下のようにします。

class Company < ApplicaionRecord
  has_many :members, as: :employer
end

class Partner < ApplicaionRecord
  has_many :members, as: :employer
end

class Member < ApplicaionRecord
  belongs_to :employer, polymorphic: true
end

Memberモデルにemployer_id, employer_typeというカラムを用意し、employer_typeに関連付けるモデルのクラス(CompanyまたはPartner)を入れると、いずれかのクラスに関連付けられ、partner.employerで呼び出すことができます。

名前空間を付与したクラスのpolymorphic関連

polymorphic関連はクラス名を指定する必要があるため、前述のnamespace付加と組み合わさると正しく動作しません。 そこでAlpha側に以下のようなコードが書かれていました。 (concerns以下に置かれ、必要なモデルでincludeしている)

module PolymorphicBravo
  module ClassMethods
    def has_many_polymorphic_bravo(relation_name, as:, **options)
      class_names = [name, "Alpha::#{name}"]
      has_many relation_name, -> { where("#{as}_type" => class_names) },
               options.merge(foreign_key: "#{as}_id")
    end

    def has_one_polymorphic_bravo(relation_name, as:, **options)
      class_names = [name, "Alpha::#{name}"]
      has_one relation_name, -> { where("#{as}_type" => class_names) },
              options.merge(foreign_key: "#{as}_id")
    end
  end
end

has_many_polymorphic_bravo が宣言されると

  • has_manyを定義する
  • has_manyのscopeとしてtypeカラムに 通常のクラス名 or Alpha::のついたクラス名 の条件を加える
  • foreign_keyを設定する

という動作をします。

railsアップグレードで発生した現象

さて、これらを踏まえた上で、rubyとrailsを交互にアップグレードして行きましたが、rails6.0にアップグレードしたところで上記のpolymorphic_bravoが動作しなくなりました。 具体的には

class Company
  has_many_polymorphic_bravo :members, as: :employer
end

class Member < ApplicaionRecord
  belongs_to :employer, polymorphic: true
end

このようなモデルで company.members.build した場合に、 employer_id: company.id, employer_type: 'Alpha::Company' なMemberインスタンスが生成されなければなりませんが、rails6では employer_id: company.id, employer_type: nil のインスタンスが生成されます。

そもそもなぜtypeカラムに値が入る(入っていた)のか?

問題解決のためにrailsのコードを追っかけます。 has_manyにscopeを指定している場合、buildでscopeに合った値が入りますが、その仕組みは以下のようなものでした。 (以下、rails5.0-stableのコードを例に取ります)

company.members#build は ActiveRecord::Associations::CollectionProxy で定義されている。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/collection_proxy.rb#L292

def build(attributes = {}, &block)
  @association.build(attributes, &block)
end

@association は ActiveRecord::Associations::HasManyAssociation のインスタンスです。 ActiveRecord::Associations::HasManyAssociation#build はActiveRecord::Associations::CollectionAssociation で定義されています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/collection_association.rb

def build(attributes = {}, &block)
  if attributes.is_a?(Array)
    attributes.collect { |attr| build(attr, &block) }
  else
    add_to_target(build_record(attributes)) do |record|
      yield(record) if block_given?
    end
  end
end

build_record があからさまに怪しいです。 このメソッドは継承元の ActiveRecord::Associations::Association クラスで定義されています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/association.rb

def build_record(attributes)
  reflection.build_association(attributes) do |record|
    initialize_attributes(record, attributes)
  end
end

initialize_attributes も同クラス。

def initialize_attributes(record, except_from_scope_attributes = nil) #:nodoc:
  except_from_scope_attributes ||= {}
  skip_assign = [reflection.foreign_key, reflection.type].compact
  assigned_keys = record.changed
  assigned_keys += except_from_scope_attributes.keys.map(&:to_s)
  attributes = create_scope.except(*(assigned_keys - skip_assign))
  record.assign_attributes(attributes)
  set_inverse_instance(record)
end

はい、 create_scope が怪しいですね。 こちらは ActiveRecord::Associations::CollectionAssociation で定義されています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/collection_association.rb

def create_scope
  scope.scope_for_create.stringify_keys
end

scopeは同クラスで

def scope
  scope = super
  scope.none! if null_scope?
  scope
end

super は ActiveRecord::Associations::Association#scope なので

def scope
  target_scope.merge!(association_scope)
end

target_scopeは同クラス。

def target_scope
  AssociationRelation.create(klass, klass.arel_table, klass.predicate_builder, self).merge!(klass.all)
end

なんだか複雑なコードだけども、とりあえず AssociationRelation のインスタンスを返していることが分かります。

create_scope に戻って AssociationRelation#scope_for_create を探すと、継承元の Relation クラスに定義されていました。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/relation.rb

def scope_for_create
  @scope_for_create ||= where_values_hash.merge(create_with_value)
end

where_values_hash は同クラス。

def where_values_hash(relation_table_name = table_name)
  where_clause.to_h(relation_table_name)
end

where_clause は includeされたモジュール QueryMethods でメタプログラミングされています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/relation/query_methods.rb

Relation::CLAUSE_METHODS.each do |name|
  class_eval <<-CODE, __FILE__, __LINE__ + 1
    def #{name}_clause                           # def where_clause
      @values[:#{name}] || new_#{name}_clause    #   @values[:where] || new_where_clause
    end                                          # end
                                                 #
    def #{name}_clause=(value)                   # def where_clause=(value)
      assert_mutability!                         #   assert_mutability!
      @values[:#{name}] = value                  #   @values[:where] = value
    end                                          # end
  CODE
end

とりあえず置いといて、 to_h は一見組み込みメソッドだけど、requireされた ActiveRecord::Relation::WhereClause でオーバライドされています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/relation/where_clause.rb

def to_h(table_name = nil)
  equalities = predicates.grep(Arel::Nodes::Equality)
  if table_name
    equalities = equalities.select do |node|
      node.left.relation.name == table_name
    end
  end

  binds = self.binds.map { |attr| [attr.name, attr.value] }.to_h

  equalities.map { |node|
    name = node.left.name
    [name, binds.fetch(name.to_s) {
      case node.right
      when Array then node.right.map(&:val)
      when Arel::Nodes::Casted, Arel::Nodes::Quoted
        node.right.val
      end
    }]
  }.to_h
end

詳細は省きますが、ここでscopeに与えられたwhere節がハッシュに変換され、 scope_for_create.stringify_keys に渡ってbuildされるモデルのattributesに追加されています。

rails6で動作しない理由

rails6では上記の scope_for_create のところが少し違って

https://github.com/rails/rails/blob/6-1-stable/activerecord/lib/active_record/relation.rb

def scope_for_create
  hash = where_clause.to_h(klass.table_name, equality_only: true)
  create_with_value.each { |k, v| hash[k.to_s] = v } unless create_with_value.empty?
  hash
end

となっています。 ActiveRecord::Relation::WhereClause#to_h も変更され

def to_h(table_name = nil, equality_only: false)
  equalities(predicates, equality_only).each_with_object({}) do |node, hash|
  next if table_name&.!= node.left.relation.name
  name = node.left.name.to_s
  value = extract_node_value(node.right)
  hash[name] = value
end

となりました。 equalities はメソッドに切り出され

def equalities(predicates, equality_only)
  equalities = []

  predicates.each do |node|
    if equality_only ? Arel::Nodes::Equality === node : equality_node?(node)
      equalities << node
    elsif node.is_a?(Arel::Nodes::And)
      equalities.concat equalities(node.children, equality_only)
    end
  end

  equalities
end

になっています。 詳細は省きますが、where_clauseノードが Arel::Nodes::Equality もしくは Arel::Nodes::And 以外は条件に入らないようになっています。 predicates は Arel::Nodes::* クラスの配列で、 クエリを構造体にしたものが入っています。 そして、 通常の(=で比較する)WHERE条件は Arel::Nodes::Equality クラスですが、 IN句 は Arel::Nodes::Casted クラスになります。

つまり、rails5では Arel::Nodes::Casted クラスもscope_for_createのハッシュに格納されていましたが、 6では弾かれるようになりました。 これがbuildでtypeカラムがnilになる原因でした。

どうしてそうなった?

バグ修正 です。 この修正が入ったのは PR#41319。

If a scope has IN cluase, scope_for_create which is passed to assign_attributes will include array values, and it will cause weird behaviors.

大変ごもっともです。だっておかしいもん。 IN句に与えられた配列がハッシュの値として格納された結果、配列の最初の値が初期値として与えられていました。 つまり has_many_polymorphic_bravo はこのバグによって一見正常に動作していたに過ぎなかったのです。 めでたしめでたし。

めでたくない。これを再実装するのは困難を極め、結局データ構造を大幅に変更してrails標準に近づけることになりました。 モンキーパッチはアップグレードの敵なのでやめましょう。

余談1 ところで「のび犬」って?

余談ですが、このように どちらでも動作するように配慮してしまう ことを個人的に のび犬問題 と呼んでいます。 「ドラえもん」で のび太が「太」の漢字の点が上だったか下だったか分からなくなり、両方に打った という故事(てんとう虫コミックス23巻「透視シールで大ピンチ」)に由来します。 多分他に誰も呼んでないと思う。 プログラミングをする時には曖昧さはできるだけ排除するべきである、という教訓でした。

余談2 調査方法

継承やメタプログラミングが多用されるrailsでは、コードを追っていくのが難しいですね。 今回はrails console+pry byebugを活用して追跡しました。 例えばrails console上で以下のようにして、コンテキストを切り替えて深く潜っていくことができます。

> company = company.new
> cd company #=> コンテキストがcompanyに切り替わる
> members #=> company.membersを返す
> @name #=> インスタンス変数の中身も見れる

メソッドの定義を探すには以下のようにします。

> company.members.method(:build).source_location #=> .../versions/2.6.3/lib/ruby/gems/2.6.0/gems/activerecord-5.0.7.2/lib/active_record/associations/collection_association.rb"

これらを上手く使えば、変数やメソッドの戻り値を参照しつつコードを追っていくことができます。

また、debug gemを使えばコードにブレークポイントを埋め込まずとも、コマンドラインオプションで任意の場所にブレークポイントを仕込むことができます。railsの任意の地点に潜っていくことができるので非常に便利です。 が、これを使うためにはruby3.0+(2.7にもバックポートされています)にアップグレードしなければならないので、卵が先か鶏が先か問題になってしまい、今回は使えませんでした。