iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作る

こんにちは、クックパッドマートプロダクト開発部の佐藤(@n_atmark)です。

普段はクックパッドマートのモバイルアプリ開発に従事しています。 今回、iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作ってみたのでその紹介を行います。

動作している物を見ていただくのが分かりやすいと思うので、早速ですが動作イメージがこちらになります。

フレームインスペクタの動作の様子
フレームインスペクタの動作の様子 (gif)

アプリに実装されたUI要素を長押しすると、スクリーンとの距離やUI要素のサイズ、角丸の半径を表示するようにしています。 また、2本指で二つのUI要素を長押しすると、長押ししたUI要素間のマージンを表示するようにしています。

開発の背景

私が普段開発に従事しているクックパッドマートiOSアプリでは元々5の倍数マージンを採用していたのですが、これを4の倍数マージンに変えたいという背景がありました。

後発のクックパッドマートAndroidアプリで4の倍数マージンを採用しており、デザイナーが画面デザインを作成するために5の倍数マージン / 4の倍数マージンを切り替えてデザインを作らないといけないという課題があり、どちらかに統一したいという要望がありました。クックパッド社内の他のiOSアプリでも4の倍数マージンを採用していることもあり、クックパッドマートiOSアプリも4の倍数マージンに合わせることになりました。

しかし、マージン値を機械的に置き換えるのは難しく、現在は気づいた箇所から徐々に置き換えていく方針で進めています。 クックパッドマートiOSアプリにはUIKit (AutoLayout) で作られた画面もあればSwiftUIで作られた画面もあり、マージンを直接設定している箇所や変数に置いている箇所、アニメーションのためにマージン値を切り替えている箇所などがあるため、統一した方法で置き換えができないためです。また、10ptの箇所を8ptに置き換えるべきか12ptに置き換えるべきかといった問題もあります。

そこで、マージンの違いに気づきやすくするという目的で今回のツールを開発しました。 QA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みとして用意しています。

実装の紹介

実装の全体は https://gist.github.com/natmark/ef27845aff19059e74916df421223b79 に置いてあります。

final class DebugFrameInspectorView: UIView {
   init() {
        super.init(frame: .zero)
        backgroundColor = .clear
        isUserInteractionEnabled = false
    }
   // … 略
}

DebugFrameInspectorView がマージンやフレームサイズを表示しているViewです。 backgroundColor = .clear かつ isUserInteractionEnabled = false なViewとなっていて、これを keyWindow に対して addSubView(_:) して利用してもらう想定です。

func setup() {
    let singleLongPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(didSingleLongPress(_:)))
    singleLongPressGestureRecognizer.minimumPressDuration = 0.2
    singleLongPressGestureRecognizer.numberOfTouchesRequired = 1
    window?.addGestureRecognizer(singleLongPressGestureRecognizer)

    let doubleLongPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(didDoubleLongPress(_:)))
    doubleLongPressGestureRecognizer.minimumPressDuration = 0.2
    doubleLongPressGestureRecognizer.numberOfTouchesRequired = 2
    window?.addGestureRecognizer(doubleLongPressGestureRecognizer)
}

setup() メソッドの中で UILongPressGestureRecognizerUIWindow に追加して長押しのジェスチャーを補足できるようにしています。

@objc private func didSingleLongPress(_ sender: UILongPressGestureRecognizer) {
     if sender.state == .began {
         let positionInWindow = sender.location(in: window)
         if let hitView = window?.hitTest(positionInWindow, with: nil) {
             let positionInHitView = sender.location(in: hitView)
             let globalRect = CGRect(
                 x: positionInWindow.x - positionInHitView.x,
                 y: positionInWindow.y - positionInHitView.y,
                 width: hitView.frame.size.width,
                 height: hitView.frame.size.height
             )
             singlePressValue = SinglePressValue(
                 viewWireframe: .init(
                     rect: globalRect,
                     cornerRadius: hitView.layer.cornerRadius,
                     maskedCorners: hitView.layer.maskedCorners
                 )
             )
             setNeedsDisplay()
         }
     } else if sender.state == .ended {
         singlePressValue = nil
         setNeedsDisplay()
     }
 }

 @objc private func didDoubleLongPress(_ sender: UILongPressGestureRecognizer) {
    // 略
 }

長押し時の処理がこちらになります。1本指で長押しするか2本指で長押しするかによって didSingleLongPress(_:) didDoubleLongPress(_:) と実装を分けていますが、内容としてはほぼ同じ処理になります。

let positionInWindow = sender.location(in: window)keyWindow 内におけるタッチ位置を取得した後 hitTest(_:with:) を用いてタップされたViewを特定しています。 (let hitView = window?.hitTest(positionInWindow, with: nil) の箇所)

その後タップされたViewのframe (superviewを基準にした相対位置) を keyWindow 内における座標に変換したいので、hitView 内でのタッチ位置の座標を取得し ( let positionInHitView = sender.location(in: hitView) の箇所) 、 positionInWindow から positionInHitView の座標分ずらすことで keyWindow 内における座標を取得しています。

let globalRect = CGRect(
   x: positionInWindow.x - positionInHitView.x,
   y: positionInWindow.y - positionInHitView.y,
   width: hitView.frame.size.width,
   height: hitView.frame.size.height
)

singlePressValuedoublePressValue という変数に値を保持しておいて setNeedsDisplay() を呼び出すことで draw(_:) メソッドを呼び出し、フレーム境界やマージンなどの線の描画を行っています。

線の描画に関しては詳しく触れませんが、CoreGraphicsを用いてゴリゴリ記述しています。

利用方法

/// SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }

    let window: UIWindow
    // 以下はアプリの構成によって変わるので必要箇所だけ入れてください
    if let keyWindow = windowScene.keyWindow {
        // keyWindowがある場合 (SwiftUI.App利用時)
        window = keyWindow
    } else if let firstWindow = windowScene.windows.first {
        // keyWindowはないが、windowが存在する場合 (Main Storyboard利用時)
        window = firstWindow
        window.makeKeyAndVisible()
    } else {
        // window自体存在しない場合 (Storyboard不使用時)
        window = UIWindow(windowScene: windowScene)
        window.makeKeyAndVisible()
        window.rootViewController = MyRootViewController()
        self.window = window
    }

    #if DEBUG
    let debugFrameInspectorView = DebugFrameInspectorView()
    window.addSubview(debugFrameInspectorView)
    debugFrameInspectorView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        window.leadingAnchor.constraint(equalTo: debugFrameInspectorView.leadingAnchor),
        window.trailingAnchor.constraint(equalTo: debugFrameInspectorView.trailingAnchor),
        window.topAnchor.constraint(equalTo: debugFrameInspectorView.topAnchor),
        window.bottomAnchor.constraint(equalTo: debugFrameInspectorView.bottomAnchor),
    ])
    debugFrameInspectorView.setup()
    #endif
}

keyWindow となるUIWindowに対して addSubView(_:) した後、 setup() を呼び出すことで利用できます。

デバッグ用の機能なのでCompilation conditionsを使ってbuild configurationが DEBUG の時のみ有効にするなどしておくことをオススメします。

実際に開発したツールを使ってもらって

開発の背景の箇所にもある通り、今回のツールの目的としてはQA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みを用意することでした。

チームメンバーに実際に使ってもらうと、以下のようなコメントをもらいました。

ポジティブな意見

  • (エンジニア) 実際のアプリのマージンを実装を見に行かなくても確かめられるのは嬉しい
  • (デザイナー) Webだとインスペクタですぐ要素を確認できるのにネイティブアプリだと見ることができないので、アプリでもフレーム値を確認できるのがありがたい

追加要望

  • (デザイナー) 実装されているフォントサイズや色も確認できると嬉しい

ネガティブな意見

  • (エンジニア) ボタンの領域を長押しした時に、ボタン内の要素のフレームサイズが見れない
  • (エンジニア) フレームサイズが実装を行った際に意図したサイズと違って表示されることがある

実装されたアプリ上でマージン値をサクッと確かめられることに対して一定効果がありそうなことが分かりました。 また、フォントサイズや色も確認できるとアプリに実装されたデザインが正しいかどうかを確認する用途で、より便利に使えそうです。

一方で、前後に重なった要素に対してはフレームサイズの確認がうまくできないという課題や、実際の実装値と表示される値が違うことがあるという課題も浮き彫りになりました。

実装が難しい部分の紹介

ここまで開発したフレームインスペクタの紹介をしましたが、できないことも結構あります。 先ほどチームメンバーから要望のあった「実装されているフォントサイズや色」といった要素や、「実装値と表示値が違う」という課題の解決も現状難しいと感じている点です。

その難しさの元になるのがSwiftUIで実装された画面です。

UIKitとSwiftUIで以下のような画面を実装したとします。

UIKit

SwiftUI

UIWindowsubViews を辿って階層構造を表示するとそれぞれ下のようになります。

UIKit SwiftUI
UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ UIView
      └ UIStackView
      ├ UIStackView
      │ ├ UIImageView ()
      │ └ UILabel (タイトル)
      └ UILabel (テキストテキストテキスト)
UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ _UIHostingView<ModifiedContent<AnyView, RootModifier>>
    ├ _UIGraphicsView ()
    ├ CGDrawingView (タイトル)
    └ CGDrawingView (テキストテキストテキスト)

UIKitの場合は UIImageViewUILabel といったお馴染みのViewクラスなので、 UIView 型の subview をキャストすれば簡単にプロパティを確認できますが、SwiftUIの場合はSwiftUIのView構造ではなく、描画用のクラスである SwiftUI._UIGraphicsViewSwiftUI.DisplayList.ViewUpdater.Platform.CGDrawingView といったprivateなクラスが利用されます。

これが一つ目の難しいポイントで、SwiftUIでViewを組み立てた時にどういったModifierが適用されたかといった情報を持たないため、これらからフォントサイズや設定された色を取り出すことは困難です。

また、UIKitではViewの階層構造が保持されるのに対して、SwiftUIでは _UIHostingView というクラスの配下にフラットに展開されてしまいます。 SwiftUIのViewを組み立てる時に利用した VStackHStack は、フレームの決定だけに用いられUIViewの世界においては現れません。 これが二つ目の難しいポイントです。

VStackHStack は、フレームの決定だけに用いられUIViewの世界においては現れない」というのがどう難しさにつながっているのか説明するために以下の図を用意してみました。

UIKit SwiftUI

これは UIWindowsubviews を辿ってそれぞれの CALayer に対して枠線を表示したものです。

SwiftUI側の実装で、「タイトル」および「テキストテキストテキスト」には .frame(maxWidth: .infinity, alignment: .leading) をつけているものの CGDrawingView が表現するフレームはテキストが文字幅に縮んでしまっていることがわかると思います。

元々VStackに設定していた .padding(.horizontal, 20) のうち、trailing 側に関しては正しく効いているかどうか、今回のフレームインスペクタでは上手く確認することができません。

補足

ちなみに VStack および HStack.background(Color.white) を追加するとVStack/HStackの領域が描画され、下のように画面幅にフレームが広がっているのを確認できます。

View構造 フレームレイアウト
UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ _UIHostingView<ModifiedContent<AnyView, RootModifier>>
    ├ _UIGraphicsView (HStackのbackground)
    ├ _UIGraphicsView (VStackのbackground)
    ├ _UIGraphicsView ()
    ├ CGDrawingView (タイトル)
    └ CGDrawingView (テキストテキストテキスト)

今後の展望

今回作ったフレームインスペクタではSwiftUIで作った画面表示にまだ課題があることを紹介しました。 ところでXcodeには Debug View Hierarchy というデバッグ機能があることはよく知られていると思います。

この Debug View Hierarchy を用いるとSwiftUIで開発された画面に関しても Horizontal StackText といったSwiftUIで組み立てたViewの構造や、テキストのフォントといったModifierも確認することができます。

どうにか Debug View Hierarchy で表示しているような情報を取得できると、今回開発したフレームインスペクタに機能追加することができそうです。

_UIHostingView._viewDebugData()

SwiftUI._UIHostingView に非公開APIとして _viewDebugData() というメソッドが存在します。これを用いるとデバッグ用にSwiftUIのView構造を解析できそうです。 (https://apurin.me/articles/swiftui-secrets/ を参考にさせていただきました)

SwiftUIのView構造をもう一度示すのですが

UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ _UIHostingView<ModifiedContent<AnyView, RootModifier>>
    ├ _UIGraphicsView ()
    ├ CGDrawingView (タイトル)
    └ CGDrawingView (テキストテキストテキスト)

階層を辿ることで _UIHostingView を取得できそうです。

struct ViewDebugData {
    let data: [_ViewDebug.Property: Any]
    let childData: [ViewDebugData]
}
protocol DebuggableSwiftUIView {
    func viewDebugData() -> [ViewDebugData]
}
extension _UIHostingView: DebuggableSwiftUIView {
    func viewDebugData() -> [ViewDebugData] {
        let _viewDebugData = _viewDebugData()
        return unsafeBitCast(_viewDebugData, to: [ViewDebugData].self)
    }
}

DebuggableSwiftUIView というプロトコルを用意して _UIHostingView に準拠させています。

private func digDebuggableSwiftUIView(from view: UIView) -> (any DebuggableSwiftUIView)? {
     if let debuggableView = view as? DebuggableSwiftUIView {
         return debuggableView
     } else {
         for subView in view.subviews {
             if let debuggableView = debuggableView(from: subView) {
                 return debuggableView
             }
         }
         return nil
     }
}

DebuggableSwiftUIViewに準拠したViewを探索するメソッドを用意して

guard let window else { return }
let viewDebugData = digDebuggableSwiftUIView(from: window)?.viewDebugData()
print(viewDebugData)

viewDebugData() を呼び出すことで、_UIHostingView._viewDebugData() の結果を確認できそうです。

_viewDebugData() の出力はかなり大きいので出力の全体は https://gist.github.com/natmark/0c13ded1ae0bf97f1f4bbd991f9e0118 に置いておきます。

SwiftUI._ViewDebug.Property.type の部分だけ階層表示すると

_SafeAreaInsetsModifier
  └ ModifiedContent, EditModeScopeModifier>, HitTestBindingModifier>
    └ HitTestBindingModifier
      └ ModifiedContent, EditModeScopeModifier>
        └ EditModeScopeModifier
          └ ModifiedContent<_ViewModifier_Content, TransformModifier>
            └ TransformModifier
              └ _ViewModifier_Content
                └ ModifiedContent
                  └ RootModifier
                    └ ModifiedContent, RootEnvironmentModifier>, PresentedSceneValueInputModifier>
                      └ PresentedSceneValueInputModifier
                        └ ModifiedContent<_ViewModifier_Content, RootEnvironmentModifier>
                          └ RootEnvironmentModifier
                            └ _ViewModifier_Content
                              └ AnyView
                                └ ContentView
                                  └ ModifiedContent>>, _FrameLayout>, ModifiedContent)>>, ModifiedContent)>>, _PaddingLayout>
                                    └ _PaddingLayout
                                      └ VStack>>, _FrameLayout>, ModifiedContent)>>, ModifiedContent)>>
                                        └ Tree<_VStackLayout, TupleView<(HStack>>, _FrameLayout>, ModifiedContent)>>, ModifiedContent)>>
                                          ├ _FlexFrameLayout
                                          ├  └ Text
                                          ├    └ AccessibilityStyledTextContentView
                                          ├      └ ModifiedContent, AccessibilityLargeContentViewModifier>
                                          ├        └ AccessibilityLargeContentViewModifier
                                          ├          └ ModifiedContent
                                          ├            └ AccessibilityAttachmentModifier
                                          ├              └ StyledTextContentView
                                          └ HStack>>, _FrameLayout>, ModifiedContent)>>
                                            └ Tree<_HStackLayout, TupleView<(ModifiedContent>>, _FrameLayout>, ModifiedContent)>>
                                              ├ _FlexFrameLayout
                                              ├  └ Text
                                              ├    └ AccessibilityStyledTextContentView
                                              ├      └ ModifiedContent, AccessibilityLargeContentViewModifier>
                                              ├        └ AccessibilityLargeContentViewModifier
                                              ├          └ ModifiedContent
                                              ├            └ AccessibilityAttachmentModifier
                                              ├              └ StyledTextContentView
                                              └ _FrameLayout
                                                └ Image
                                                  └ ModifiedContent
                                                    └ AccessibilityAttachmentModifier
                                                      └ Resolved

のようになっており、Viewの階層構造が取れそうです。 また、SwiftUI._ViewDebug.Property.type: SwiftUI.Text の箇所だけピックアップしてみると

SampleSwiftUIView.ViewDebugData(
  data: [
    SwiftUI._ViewDebug.Property.value: SwiftUI.Text(
        storage: SwiftUI.Text.Storage.anyTextStorage(<LocalizedTextStorage: 0x00006000017f54a0>: \"テキストテキストテキスト\"), 
        modifiers: [SwiftUI.Text.Modifier.font(Optional(SwiftUI.Font(provider: SwiftUI.(unknown context at $105d9f910).FontBox<SwiftUI.Font.(unknown context at $105e5aae8).SystemProvider>)))]
    ), 
    SwiftUI._ViewDebug.Property.type: SwiftUI.Text, 
    SwiftUI._ViewDebug.Property.size: (191.0, 20.333333333333332), 
    SwiftUI._ViewDebug.Property.position: (20.0, 442.5)
  ],
  childData: [] // 略
)

Modifierに関しても確認することができました。

_viewDebugData の内容を実際にデバッグツールに活用しようと思うとかなり骨が折れる作業になりそうですが、SwiftUIのView構造をここまで詳細に取得できれば、Viewのデバッグツールを作るにあたってできることは広がりそうです。

まとめ

今回、アプリに実装されたUI要素のフレームサイズやマージンを簡単に確認できるツールを作って紹介しました。

チームメンバーに使ってもらって、サクッとフレームサイズやマージンを確認する用途であれば便利に使えそうなことが分かった一方で、より完成度の高いツールを目指そうとした際に、SwiftUI製のViewのインスペクタ実装は難しい部分が多いことも分かりました。

_UIHostingView のprivate APIである _viewDebugData() を使うと詳細なSwiftUIのView構造を利用できそうなこと分かったため、 _viewDebugData() のデータを活用したデバッグツールの改善に関しても引き続き検討してみようと考えています。

今回の記事が快適なデバッグ環境構築の参考になれば嬉しいです。

クローズしたサービスの管理画面を静的サイトにする

こんにちは、技術部の石川です。

ある日、社内の各種アプリケーションを眺めている中で、とあるクローズしたサービスの管理画面を担っていたウェブアプリが今も動いていると気付きました。簡単にヒアリングしたところ、サービス自体はクローズしたものの、保有していたデータが次のチャレンジに生かせるため管理画面だけ残しているとのことでした。

一方で、その管理画面へのアクセスはそう多くありませんでした。毎日ちょっとだけのリクエストを処理するためだけにデータベースとサーバーが動いており、少し無駄がある状態になっていました。

やや気になったので検討した結果、最終的にこの管理画面アプリを Next.js 製の静的なデータビューワーサイトとしてリニューアルし、社内向けの GitHub Pages として提供されている状態にできました。この記事ではその顛末をご紹介します。

技術選定

いくつか事前調査をした結果、今回の管理画面について以下のことが分かりました。

  • Rails が動いている。サーバー側では graphql-ruby を利用した GraphQL API が動いていて、ウェブフロントエンド側では素の React が API にリクエストを行いながらページを作っている。データベースは PostgreSQL (Amazon RDS for PostgreSQL)。
  • データ量はそこまで多くないが、目で全件確認できるほど少なくもない。
  • ページの種別は、Rails の app/javascript/pages 下にある index.tsx を数えてみると 80 程度。移植しなくて良いページもそれなりにありそう。
  • 画像や映像を表示しているページがある。
  • データの追加や更新はもう行わない。
  • 認証・認可は不要になる。正確には、社内ネットに閉じた環境であれば全公開で構わない。
  • 予定としては今後数年アクセスするつもりがある。
  • データの一覧ページのところにある検索機能は残したい。

この状況下で、現状の管理画面アプリに替わる運用として以下の選択肢を考えました。

  • DWH へのクエリで済ませてしまう。クックパッドでは Amazon Redshift に各種データを集積し、DWH として活用しています。*1今回のアプリが利用しているデータベース上の情報は DWH にもあるため、キレイな画面は消してしまって素朴な SQL クエリにしてしまうことは可能です。クエリ結果を共有するアプリが常用されていたりもします。*2
  • BI ツールのダッシュボードとして再実装する。
  • データベースやアプリをサーバーレスな構成に移植する。
  • 静的サイトジェネレーターの何かしらを使って再実装する。
  • 今ある管理画面に対して古典的なウェブスクレイピングを行って全ページのファイルを取得し、それを手直ししたうえで静的サイトとして提供する。検索機能は諦めて、ブラウザのページ内検索を使う。

このうち、DWH 案とダッシュボード案はすぐに取り下げました。画像や映像とテキストが横に並んだ状態でパッと一覧できる現状を保ちたいという利用者からの要望があったのと、再実装したいページ種別数がそれなりにあってクエリやダッシュボードとして作るには時間がかかりそうだったのが理由です。

サーバーレス化をやってみるのも面白そうではありました。たとえばデータベースだけ Amazon Aurora Serverless にしてアプリはそのまま、というのは社内に過去事例もありできそうでした。一方でアプリが残る以上そのうちセキュリティアップデート等の対応は必要になるため、もっとラクができるなら嬉しいと考えました。クローズしたサービスのアプリはオーナー不在になる確率が高そうで、誰がメンテナンスするのかという問題もありました。*3

というわけで静的サイトジェネレーターかスクレイピングの 2 択になりました。ラクそうだったスクレイピングを選んでも良かったのですが、ここで少し欲を出して、静的サイトジェネレーターを試してみたいという気分になりました。

静的サイトジェネレーターを使う場合、元々の画面が React で実装されているので、移植の容易さを考えると Next.js や Gatsby などの選択肢が考えられます。実は Gatsby で静的サイト化するのは別の小さな社内向けアプリで前例がありました。ただ 2023 年現在の社内では Next.js を利用しているアプリが多くあり、また Next.js の静的サイト向け機能である Static Exports を自分で使ってみたことがなかったため、技術検証の意味も込めて Next.js を使ってみることにしました。*4

React 以外の依存関係も確認しておきましょう。元々の管理画面のコードで使われている package.json を見てみたところ、UI ライブラリとして Ant Design (antd)、GraphQL クライアントとして Apollo が入っており、その他小さなライブラリがすこし入っているという状況でした。このくらいの複雑度なら移植できそうだなと判断しました。

さて、Next.js の Static Exports で再実装するのであれば、PostgreSQL に入っているデータをどうするか考えなければいけません。AWS 上にデータベースが残ったままだとコストはあまり減りませんし、ローカル環境のデータベースに移すならシンプルな形にしたいです。考えた結果、今回のデータはそこまで巨大では無かったため、SQLite へ移植して .sqlite3 ファイルとして持ってしまうことにしました。jsonb 型など PostgreSQL 固有の機能を使っている箇所もあったのですが数箇所しか無かったため SQLite 向けに書き直し、SQLite のみだと足りない処理はデータベースからデータを取ってきた後にアプリ側で行うようにしてしまいました。

データ取得まわりについては GraphQL を使うことにしました。元々の Rails 製 GraphQL API をコピペしてくれば GraphQL スキーマとサーバーが出来ますし、クライアント側でも型が自動生成できてラクが出来そうという目論見がありました。SQLite に対して直接 SQL クエリを走らせても良かったのですが、自分が元の管理画面の実装にそこまで詳しくなかったというのもあり、どこまで複雑なクエリが必要になるのか実装前の時点で判断しづらかったためコピペにしてしまいました。

実装

方針が決まってしまえば後は実装するだけです。API サーバーは元の Rails のコードをそのままコピペしてきて動かすようにし、フロントエンドは create-next-app しました。Next.js 13 で App Router を使いつつ、Static Exports のために next.config.js が

const nextConfig = {
  output: 'export',
}
module.exports = nextConfig

になっています。*5

next build をする際には横で SQLite に繋がった Rails 製 GraphQL API を動かしていて、必要に応じて API にリクエストすることでデータを取ってきます。Static Exports の場合はビルドが終わると HTML 等のファイル群が生成されるので、開発環境では serve を使うなどすれば閲覧できます。

実装の移植について、ページや React Components の移植はまあまあコピペで終わりました。元々の実装がそれなりにコンポーネント化されていて全容を把握しやすかったのと、元と変わらず GraphQL を使っているあたりが効きました。

ただし Server Components と Client Components、つまりどのコンポーネントの処理はサーバー側で行われどの処理はブラウザ側で行われるのかについては整理する必要がありました。Static Exports を行ううえでは API からデータを取得する部分はすべてサーバー側で行われていないといけませんし、逆に useState を使うような箇所はブラウザ側で行われなければなりません。元々の管理画面ではブラウザから GraphQL リクエストを行っていたため、至るところにある API リクエストはサーバー側に集中するよう書き換えが必要でした。データの流れを整理した結果としてひとつのコンポーネントを分割して Server Component と Client Component に分けたりもしました。

とはいえ「データの追加や更新はもう行わない」という制約がとても強く効いていて、実装は比較的シンプルになりました。データの追加・更新のために存在していた React コードをばっさり削除していった結果、大抵のページは「Server Components でデータを取得して、子となる UI コンポーネントにデータを渡す。子コンポーネントは必要であれば Client Components にする」くらいの単純さになりました。

Client Components を用いたのは主に、検索フォームを設置しているところと antd 5.8 が必要とするところです。*6

検索機能については、元の管理画面ではサーバー側に検索機能を実装していましたが静的サイトでは不可能なので、ブラウザ側の JavaScript で素朴に String.prototype.includes を使って絞り込むことで実現しました。ページネーションして見た目上の表示件数を減らしはしましたが、それなりの数のデータがあっても高速に動作するのでブラウザは凄いですね……。もし複雑な全文検索が欲しくなる箇所があれば Lunr.js 等を使ってみるつもりでしたが、今回はそこまで複雑な検索は無かったため使わずに済んでしまいました。

そんなこんなで詳細が固まり、まずは複雑そうなページから実装してみたところ上手く動いたため実装を進め、必要なページすべてについて実装しきることができました。

振り返り

全部終わったあと振り返ってみると、Next.js の App Router と GraphQL を使うことにしたのは成功だったと感じます。コピペできたのもそうですし、実装している最中、App Router のディレクトリ構造を使って GraphQL クエリや小さい React Components のファイルたちをページの実装の近くに配置できるのがラクでした。

具体的にはたとえば graphql-codegen で near-operation-file を使うようにしたうえで、以下のようなファイル配置になるわけです。

app
├── _lib
│   └── types.generated.ts
├── recipes
│   ├── _lib
│   │   ├── Table.tsx
│   │   ├── query.generated.ts
│   │   └── query.graphql
│   └── page.tsx
:
:

元々の実装もページごとのコードとページ固有の React Components、それとたまに全体で共有の React Components という感じだったので、それをそのまま持ってくることが出来ました。データの流れを整理する過程で小さい Server Components や Client Components が生まれたのですが、このディレクトリ構造だと気になりません。

Static Exports は何の問題もなく動いてくれました。移植の際に <a> タグをすべて next/link の <Link> に書き換えたため、ただでさえ静的サイトで速いのに prefetch のおかげで更に速く感じられるサイトが出来上がりました。

いちおう実装前の不安点として、antd などの依存ライブラリは実装時点での最新バージョンまで上げないと App Router および React Server Components 対応の不充分な点がありそうだとは分かっていました。この関係で元の管理画面で使っていたバージョンからメジャーバージョンを上げないといけないライブラリもありました。とはいえ実装前にザーッと各ライブラリの変更履歴を眺めた結果そこまで困らなさそうと判断し、実際あまり困りませんでした。

また App Router の関係なのか Static Exports の関係なのかはちゃんと調べていませんが、いくつかのエラーで原因を調べるのに少し時間がかかりました。エラーメッセージとスタックトレースが分かりづらかったのですよね……。"use client"; をつけて Client Components にしたら直るのだけどエラーメッセージから直接は分かりづらい類のエラーにはいくつか遭遇しました。

とはいえ始まりから終わりまで見ると、そう労力をかけずに移植できたので満足です。この管理画面が提供しているデータは社内を見渡してもそれなりにユニークな料理データでして、これを参照しやすい形で残し続けられることには価値があると、個人的にも考えています。そういった意義のついでに技術検証も出来たオトクな仕事でした。

*1:https://techlife.cookpad.com/entry/2019/10/18/090000

*2:https://techlife.cookpad.com/entry/2021/06/11/120000

*3:もちろん静的サイトにしたとしても JavaScript ライブラリの更新が必要になる可能性が無いとは言えないのですが。数年くらい経って新しいブラウザーでうまく動かなくなったときくらいでしょうかね。

*4:この管理画面を開発していたエンジニアはサービスクローズ後も社内に在籍しているのですが、このあたりの技術検証をしてみたいという自分の要望から自分が実装してみることにしたのでした。

*5:最終的な next.config.js では更に、next/image の最適化は Static Exports では意味が無いので unoptimized: true にしたり、実装の都合で trailingSlash: true にしたりもしています。GitHub Pages にデプロイする前には basePath を調整するのもやっています。

*6:antd で必要になるのは https://ant.design/docs/react/use-with-next#using-nextjs-app-router に書かれている "if you use the above sub-components in your page, you can add "use client" to the first line of the page component to avoid warnings" です。

Rubyの並列並行処理のこれまでとこれから

技術部の笹田です。今日で退職するので、バタバタと返却などの準備をしています。

本記事では、Rubyの並行並列処理の改善についての私の取り組みについて、おもに RubyKaigi 2022 と 2023 で発表した内容をもとにご紹介します。

並行と並列はよく似た言葉ですが、本記事では次のような意味で使います。

並行処理(concurrent processing)は、「複数の独立した実行単位が、待っていればいつか終わる(もしくは、処理が進む)」という論理的な概念で、古典的にはタイムシェアリングシステムなどが挙げられます。

並列処理(parallel processing)は、「複数の独立した実行単位のうちのいくつかが、あるタイミングで同時に動いている」という物理的な概念で、古典的には複数のCPU上で同時に実行させる、というものです。最近では、1つのCPU上で複数コアが同時に動いている、というのが普通になってきましたね。

Ruby(CRuby/MRI)は古くからThreadによる並行処理のための仕組みを提供しており、並列処理はUnixなどのプロセスなどを用いる、つまりRubyの外側(?)の機能と組み合わせて使う必要がありました。そして、Ruby 3.0から導入された Ractor で Ruby プロセス内で利用できる並列処理の仕組みが導入されました。まだまだ不十分なので、これからもっと頑張っていこう、っていう内容の記事になります。

簡単な歴史

Ruby 1.8 まで

Ruby 1.8 までは、Rubyはユーザーレベルスレッドと呼ばれる仕組みで、OSなどが提供するネイティブスレッド(下記NTとも表記、PthreadやWindows APIのスレッド)を1つつかって、複数のRubyスレッド(下記、RTとも表記、Thread.new{}で作るやつ)を管理していました。1つのネイティブスレッドで複数(M個)の Rubyスレッドを管理するので、M:1 モデルということもあります(世間的には 1:N スレッドモデルということが多いのですが記事の都合上、M:1 と書いておきます)。

M:1 (N:1) model, Green threads, user level threads, quoted from my RubyKaigi2022 talk

複数のRubyスレッドは、(1) I/Oや sleep、Mutex などで待ちが入るタイミング、(2) 一定時間(タイムスライス)経過したときのタイミングで切り替わります。I/O に関しては、select(に類する)システムコールで準備できたかを定期的に確認しながら管理します。

この手法の利点と欠点は次の通りです。

  • 利点:
    • ユーザレベルスレッドなので、生成は速い
  • 欠点:
    • 当時はRubyスレッドの切り替えをスタックを丸々コピーするという手法を使っていたので、結構遅いものでした。
    • select で待ちを制御できない処理、たとえば waitpid()flock()、ドメイン名解決、待ちが入るようなライブラリ関数などで待っていると他のRubyスレッドに切り替わらない
    • ポーリング出来る処理は都度ポーリングで対処していたが、スレッド数が増えるとスケールしない可能性がある
    • ネイティブスレッドを占有するほうが都合がよいライブラリ(GUI系など)を素直に使えない
    • 作るのが(メンテし続けるのが)結構大変

Ruby 1.9 Thread

Ruby 1.9 では、Rubyスレッド一つにつきネイティブスレッド1つ用意する 1:1 モデルに変更されました。というのも、ユーザレベルスレッドはいろいろ tricky で、実際に実装していた私には手がおえなかったからです。また、1:1 モデルにすることで、並行処理だけでなく、並列処理への拡張も視野に入っていたからです。

1:1 model, quoted from my RubyKaigi2022 talk

Ruby 1.9 で 1:1 モデルを導入する際には、GVL(Global VM Lock)というのを導入しました。ただ一つのGVLをロックしているRubyスレッドしか動かない、というモデルとすることで、同時にRubyスレッドはたかだか1つしか動かない、というのものです。そのため、Rubyスレッドは相変わらず並行処理をサポートしますが、並列処理はサポートしません。いろいろな理由はあるのですが、要は「実装が簡単だから」ということに尽きます。

(余談:どちらも簡単に拘っているのは、手抜きしたいというのが本音なのですが、建前でいうと、マンパワーのない状況で安定した処理系を提供するためには適切なトレードオフであるのではないかな、と思っており、この判断は今振り返っても妥当だったんじゃないかなと思います。2006年ごろですかね)

  • 利点:
    • 1:1 スレッドモデルは結構簡単
    • GVL があるので並列に実行されないためにさらに簡単
    • GVL を解放することで(C-extension)、ブロックする処理をしている間、他のRubyスレッドに処理を移すことができる
    • GVL を解放することで(C-extension)、複数の処理(Rubyで記述してある処理ではない)を並列に実行することができる。例えば、Bignumの時間がかかる計算や、I/O の処理(Ruby インタプリタとは独立した処理)など。
    • Rubyスレッド切り替えはネイティブスレッドによって実装されるので速い
    • スケジューリングをネイティブスレッドに任せてしまうので楽
  • 欠点:
    • 1:1 モデルなので Ruby スレッドを1つ作るたびにネイティブスレッド 1 つ必要になり、Rubyスレッド数がスケールしない(ネイティブスレッドの実装による)
    • GVL があるので並列実行できない
    • Rubyスレッド間で処理を受け渡すような処理が遅い(CPU core 間で処理を頻繁に切り替えるときに遅い)
    • スケジューリングをネイティブスレッドに任せてしまうので、Ruby側からの細かい制御が難しい

Ruby 1.8 であった問題がだいぶ解決されているのがわかると思います。そのほかはトレードオフですね。

Ruby 1.9 Fiber

Ruby 1.9 では、ユーザレベルスレッドの利点をちょっとのこしておこうということで Fiber が導入されました。Fiber は Ruby 1.8 のユーザレベルスレッドとほぼ同様ですが、タイムスライスがなく、I/O などで自動的にスイッチすることのない、という点が異なります。

Fiber は当初は Ruby 1.8 のスレッド切り替え処理をそのまま踏襲していたのですが、のちのバージョンで改善され、今では CPU ごとにアセンブラを用いて記述する、というものになりました。

Ruby 3.0 の Fiber scheduler

Ruby 3.0 で導入された Fiber scheduler は、I/O など、ブロックするような処理を契機にフックを呼び出す仕組みで、自分で Fiber をスケジュールする処理(これを総称して Fiber scheduler)を記述するための仕組みです。実際には、自分で記述するのではなく、すでにある gem を利用するといいでしょう。

  • 利点:
    • ユーザレベルスレッドの利点(低コストな生成)を得られる
    • 自分でスケジューラーを記述できる
    • タイムスライスがないため予測可能性が上がる
  • 欠点:
    • Ruby 1.8 スレッドと同じ(管理できないブロックする処理では他のFiberに切り替えられない)
    • Fiber を意識する必要がある
    • タイムスライスがない

アプリケーションに特化したスケジューラを記述できるというのは、最高性能を目指すという観点からは良いものですが、多くの場合 too much じゃないかなぁ、というのが私の感想です。

Ruby 3.0 の Ractor

そもそも Ruby スレッド、というかいわゆるスレッド一般って、変更可能(Mutable)データの共有によるデータレース、レースコンディションの問題があり、Mutexなどで正しく排他制御が必要です。なので、私は、スレッド難しいなぁ、あんまり便利にしたくないなぁ、という意見を持っていました。この問題に対処するために、例えば他の言語では次のような工夫をしていました。

  • 領域を完全に分けてしまう: Unix などのプロセス、Racket(のPlace)
  • すべてのデータを Immutable にして、Immutable なデータしか共有できないようにする: Erlang, Elixir(のProcess)
  • 型でいい感じになんとかする: Rust
  • データを共有する方法を標準化する: Java, Golang(Goroutine)
  • 実行時にやばそうなところを検知して修正する: Valgrind, Thread sanitiser, ...

Rubyで取れそうな戦略は何かな、ということを考えて、「"Immutable なデータしか共有できないようにする" なら、なんとかなりそうかな?」という発想で Ractor を設計しました。イメージとしては、Unixなどのプロセスに近いですが、共有可能な部分は共有するし、Mutable でもロックを必須とするなら共有可能にする、というところが近いです。

  • 利点:
    • 並列実行が可能
    • データレースなどの問題が原理上起こらない
  • 欠点:
    • 共有可能オブジェクトを制限するので、ふつうのRubyプログラムが multi-Ractor の上では動かない
    • 実装が悪いので色々遅い

「実装が悪い」という部分は、改善すればいいのですが、いくつか大きな問題がありました。

  • 1:1 スレッドを踏襲している(Ractor 内に 1 個 Ruby スレッドを作るが、それが 1 ネイティブスレッドを要求する)
  • すべてをとめて GC しなければいけないので Ractor 数にスケールしない

欠点の1つ目の問題から、なかなか利用されず、ではスクラッチで Ractor ベースに書き直せばよくなるか、というと2つ目の欠点である実装が悪いという点から利用も進まない、となれば順当に実装をよくしていくぞ、というのが今年の RubyKaigi 2023 での私の発表でした。

“Ractor” reconsidered, or 2nd progress report of MaNy projects

"Ractor" reconsidered - RubyKaigi 2023

要点としては、

  • Ractor はせっかく入ったんだけど
    • 既存のコードがすぐには動かないから使われていない
    • 性能が悪いことが多くてなかなか使われていない
  • とりあえず性能よくすることで、小規模なコードから使われるんじゃないだろうか
  • そのためにはこういうことをやったし、これからこういうことをするよ

Future expected situation on Ractor, quoted from my RubyKaigi2023 talk

というものでした。

M:N スレッドの導入

というわけで「こういうことをしたよ/するよ」という話です。

まず、RactorやRubyスレッドは、1:1モデルであることで、生成が遅かったり生成できる数が少なかったりします(1:1モデルの欠点)。そこで、M:N スレッドモデルを導入できないか、今(というか去年から)開発中です。

という内容が RubyKaigi 2022 での私の発表でした。

Making MaNy threads on Ruby

Making MaNy threads on Ruby - RubyKaigi 2022

ちなみに、M:N スレッドモデルの実装なので MaNy プロジェクトというコードネームを中田さんにつけてもらいました。

  • M:1スレッドは、RubyスレッドM個にたいしてネイティブスレッドが1個(Ruby 1.8 まで)
  • 1:1スレッドは、Rubyスレッド1個にたいしてネイティブスレッドが1個(Ruby 1.9~)
  • M:Nスレッドは、RubyスレッドM個にたいしてネイティブスレッドがN個

というモデルです。N をコア数にすることで、十分小さい数のN個のネイティブスレッドで、M個のRubyスレッド(例えば1000個)を賄おうというものです。これで、「1:1スレッドモデルがスケールしない」という問題が解決します。

Thread system implementation techniques, quoted from my RubyKaigi2022 talk

欠点としては、実装が複雑であることですが、そこは頑張りました/頑張っています。

同じRactorに属するRubyスレッドは同時並列には動かないので、1つしかRactorを動かさない場合はNをいくら多くしても2個以上のネイティブスレッドを使うことはありません。なので、その点はユーザレベルスレッドと同じです。 あまりRubyスレッドをよくしたいという動機はないのですが(Ractor 使ってほしい)、副次的に現在のマルチ Ruby スレッドプログラムがユーザレベルスレッドモデルを用いることで改善されることもあるかもしれません。

M:1 Thread level scheduling in a Ractor, quoted from my RubyKaigi2022 talk

ユーザレベルスレッドで問題となっていた、どーしょーもなくブロックしてしまう処理は、そのブロックしてしまう(可能性のある)処理を実行中、1:1スレッド、つまり1つのRubyスレッドが1つのネイティブスレッドを占有する、という状態にしておきます。ちなみにこの状態から戻らないと、他のRubyスレッドに処理がうつらないような気がしますが、ちゃんと移すために、準備のできたRubyスレッドを実行するためのネイティブスレッドを1個余分に追加します。つまり、N はそのようなスケジュール可能なネイティブスレッドの数であり、占有されている状態のネイティブスレッドは数に入れません(上限なしにネイティブスレッドが作られる可能性がありますが、動かないよりまし、というスタンスです)。

Handle unmanaged blocking operations, quoted from my RubyKaigi2022 talk

この工夫により、ユーザーレベルスレッドモデルで問題であった「何か処理が止まってしまう」ときに別のRubyスレッドに切り替わらなくなる、という問題が解決します。 M:Nスケジューラは、切り替わらなくなるかもしれない、といった危険のなくなった、そしてタイムスライスで切り替わる(プリエンプションがある)組み込みの Fiber scheduler みたいなもの、というとらえ方もできると思います。

このM:Nスレッドの実装は、Go language の構成によく似ています。ちょっとした違いとしては、goroutine はどれも順不同で実行できるのですが、Rubyのスレッドは同じRactorに所属している場合、同時に動くことはできない(GVLをもつRubyスレッドしか動かせない)、という制約です。この制約を満たすため、Ractor内のRubyスレッドについてのスケジューラと、どのRactorを動かすか選ぶRactorについてのスケジューラの2つのスケジューラによる構成となっています(もちろん細かい違いはほかにもいろいろあります)。

M:Nスレッドは、多くの場合で(ちゃんと作って有れば)問題ないと思われるのですが、どうしても仕組み的にネイティブスレッドの Thread local storage に依存した作りになっているコードを利用すると破綻する、という問題があります(あるRubyスレッドが異なるネイティブスレッドで実行するようになるため)。そこで、今のところ M:N スレッドモデルはデフォルトではオンにならないようにしようということにしています。より正しくは、メインRactorの中でRubyスレッドを作る場合(つまり、ふつうのRubyによるスレッドプログラムの場合)、1:1 スレッドという従来のスレッドモデルで実行されることになります。複数Ractorを利用する場合は、メインRactor以外はM:Nスレッドモデルで実行されます。

今のところ、RUBY_MN_THREADS=1 という環境変数でメインRactorでのM:Nスレッド対応を指定出来るようにする予定です。もし M:N スレッドの実装がマージされたら試してみてください。ちなみに、ネイティブスレッド数の上限Nを指定するには、今のところRUBY_MAX_CPU=nという環境変数で指定できるようにする予定です。

詳細は当該チケットをご覧ください: Feature #19842: Introduce M:N threads - Ruby master - Ruby Issue Tracking System

性能改善などはあまりきちんとできていないのですが、去年の RubyKaigi 2022 の発表で、少し述べています。場合によってはだいぶ速くなっています。

Ring example, quoted from my RubyKaig 2022 talk

Ring example, compare with Go/Loop time, quoted from my RubyKaigi 2022 talk

Ractor local GC の導入

現在 Ractor が遅いもっとも大きな理由が GC です。並列に実行される Ractor ですが、GC をするためにはすべてを止める必要があり、とめた状態で唯一のネイティブスレッド上でGCが実行されるようになっています。これは色々遅いので、RactorごとにGCをそれぞれ並列に実行する、というRactor local GCができないか試行錯誤中です(他の方が試験実装中)。

Ractor local GC, quoted from my RubyKaigi2023 talk

これを実現するためには、Ractorがなまじっか Immutable オブジェクトは Ractor 間で共有できるといった仕組みから、きちんと動かすためには分散GCが必要になります。現在、実装しながら問題を見つけ解決していくような手探りな感じで開発を進めています。来年くらいに何かご紹介できるといいですね。

Ractor local GC needs distributed GC, quoted from my RubyKaigi2023 talk

おわりに

本稿では、Rubyの並行並列処理について外観し、利点と欠点をまとめました。そして、それらの欠点を解消するために M:N スレッドモデルの実装を行っており、現状をご紹介しました。また、さらにまともな性能にするためにはRactor local GCが必要であるということをご紹介しました。

いまのところ、M:Nスケジューラは、1:1モデルにしておけば(デフォルトです)テスト通っているのですが、M:Nスケジューラを有効にすると「あれー?」というところでバグを出すので、Ruby 3.3 にマージできるのか予断を許さない感じです。がんばろ。

クックパッドでは、実はずっとこの辺をやっていました。

  • 2016: Guild (のちの Ractor)構想の発表
  • 2017: Ractor につなげるための、Fiber 周りの整理(この年にクックパッド入社)
  • 2018: Ractor の実装の検討
  • 2019: Ractor の実装(RubyKaigi 2019 では Ruby で MRI を書くための話をしていたけど)
  • 2020: Ractor の入ったRubyのリリース(Guild -> Ractor に名前が変わったのもこの年)
  • 2021: Ractor もデバッグできるようにするために debug.gem の開発(まだ Ractor では動かないんだけど)
  • 2022: M:N スケジューラ構想の発表とプロトタイプ
  • 2023: M:N スケジューラで Ractor を動かせるように

(やっていたのはこれだけじゃないけど)同じテーマで何年やってるんだ、という気もしますが、長い目で開発を支えてくれたクックパッドに深く感謝します。

そんな感じでまだまだやることがイッパイありますが、「並列並行処理を書くならRubyもいいね」といってもらえるように、これからも頑張っていきたいと思います。

読まなくてもよい余談です。私の卒論(2002)はスレッドライブラリ(pthread)の実装でして、20年たっても似たようなことしかやってねーな、という感想があります(でも、20年ずっと楽しいので幸せ。ちなみにYARV開発は2004年からなのでもうすぐ20年)。M:N スケジューラはその頃から考えてはいたんだけど、当時は逆に遅くなるかもなぁ、などと思っていたところでした。Go がだいたい同じようなことをしている、ということを確認できたので、結構自信をもって進めているという次第です。まぁ、Go はほかのランタイムを全部自分でかいているので、TLSみたいな互換性問題があまり起きないというところはあるとは思うんですが。2018年のRubyConf で、まつもとさんの部屋でこんな構想を話して、「でも2020年には間に合わないよなー」と言っていた内容が、やっと形になってきました。うまくまとめたいな。回り道しすぎ?

というわけで、またどこかで成果をおみせできることを楽しみにしています。

Happy hacking!