メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

「はじめて学ぶソフトウェアテスト技法」読書会を開催しました

こんにちは。メドピアのお手伝いをしている@willnetです。平日朝はだいたいジョギングをしているのですが、梅雨から夏にかけてジョギングしづらい日々が続くので変わりに何をすべきか考え中です。

今日は社内読書会で読んだはじめて学ぶソフトウェアのテスト技法の話を書きたいと思います。

メドピアでは、サーバサイドのテストにRSpecを利用しています。RSpecの設定や使い方などは検索すればたくさんの記事を目にすることができます。

しかし、いざテストコードを書く際にどう設計すべきなのか(具体的になにをどのように、どれくらいテストするのか)はツールの使い方と比べて検索などで調べるのが難しく、他のテストコードを参考になんとなく雰囲気で書いてしまう事が多いのではないかと思います。その結果、テストケースに抜けがでたり、逆に過剰なテストケースを作ってしまいCIの時間が伸びたりしてしまいます。

そこで、そんなテスト設計の基礎を学ぶ書籍として「はじめて学ぶソフトウェアのテスト技法」を選び、毎週の読書会で読み進めてきました。

読書会の進め方は普段と同様に、音読でキリのいいところまで読み、その後感想を話し合う形式で行いました。この本は章単位で話が独立しており、また1章も短めなのでこの方式に向いていました。

感想

特に序盤が参考になりました。この本は5つのセクションに分かれていますが、最初のセクションである「ブラックボックステスト技法」は次のような章構成になっています。

  • 同値クラステスト
  • 境界値テスト
  • デシジョンテーブルテスト
  • ペア構成テスト
  • 状態遷移テスト
  • ドメイン分析テスト
  • ユースケーステスト

これらのワードは、それなりに経験のあるエンジニアであれば一度は目にしたことがあるのではないでしょうか。このセクションを読むと、どのようなテストケースを作ると、少ない労力で大きな成果を得られるかの指針を理解することができます。

次のセクションのホワイトボックステストでは、カバレッジを測る方法(いわゆるC0, C1, C2)の概要が理解できます。

後半のセクションは、ウォーターフォールによる開発や、大規模な開発環境を前提としている記述が多く見られます。この本が書かれたのは2000年代前半(原著の初版は2003年12月31日発売)なので当然ではありますが、web開発におけるテストに直接影響するものは少ないので時間がない人は前半2つのセクションを読むだけでもよいかもしれません。とはいえ、テスト設計を広く知るためには後半のセクションも必読なので時間があればぜひ読んでみてほしいです。

まとめ

どのようにテストを設計したらいいのかわからない、という人に「はじめて学ぶソフトウェアのテスト技法」はオススメです!


是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

GitHub Appsの作成とOrgへの所有権の委譲手順

CTO室SREの @sinsoku です。

ドキュメントをテックブログとして書いておくと一石二鳥なことに気づいたので、ここに書きます。

GitHub Appsが必要なユースケース

GitHub Actions で使用できる secrets.GITHUB_TOKEN には以下の制限があります。

  • secrets.GITHUB_TOKEN を使用した操作では新しいワークフローが実行されません
    • Actions で作成したプルリクで Actions のテストが動かない
  • 他リポジトリのコードを参照できません
    • プライベートリポジトリのgemやnpmの取得ができない

この問題を回避するため、GitHub Appsで一時的なアクセストークンを生成して使用します。

なぜ Personal Access Token(PAT)を使うべきではないか?

PATを使用することは以下の理由から推奨していません。

  • PATを作成した人が退職した場合、引き継ぎを忘れると動かなくなる
  • PATの有効期限の管理が煩雑になる
    • 無期限にするのはセキュリティ上好ましくない
  • マシンユーザーの人数分の費用が増加する
    • アカウントを使い回すのはセキュリティ上好ましくない*1

GitHub Apps の作成手順

公式ドキュメント

GitHub Docs に手順が記載されているので、まだ読んだことない方は一度目を通しておいてください。

作成手順

  1. Register new GitHub App で各項目を埋めてください
    • 項目
      GitHub App name 適当な名前
      (弊社だと mpg-xxx-bot の命名規則)
      Homepage URL 適当なURL
      (弊社だと https://github.com/medpeer-dev
      Webhook 不要なので Active のチェックを外す
      Repository permissions Permissions required for GitHub Apps を参照して必要な権限を選択してください。
      例: コードを参照する場合はContents、プルリクを作る場合は Pull requests など
  2. GitHub Apps 作成したら、秘密鍵を生成する
  3. 使用したいリポジトリの Secrets に以下を設定する
    • GitHub AppsのID
    • 生成した秘密鍵

Orgに所有権を委譲する

作成したGitHub Appsの所有権をOrgに委譲し、必要なリポジトリで使えるようにOrgにインストールする必要があります。

公式ドキュメント

GitHub Docs に所有権の委譲の手順があるため、参照してください。

Ownerへの依頼手順

弊社ではSREメンバーがOwner権限を持っているため、以下のような運用にしています。

  1. 作成したGitHub Appsの所有権をOrgに委譲する
  2. Backlogで以下を記載したチケットを作成する*2
    • GitHub Appsの用途
    • GitHub Appsをアクセスするリポジトリ一覧
    • GitHub Appsの管理者アカウント

SREメンバーは 用途権限 に問題がなければ、所有権の委譲リクエストを承認します。 承認した後、以下の作業をします。

  • GitHub Apps に管理者を追加する
  • GitHub Apps をOrgにインストールする
    • Only select repositories で指定のリポジトリだけ許可する

アクセストークンの使い方

GitHub Actionsでの利用

tibdex/github-app-token を使用します。

README の引用ですが、以下のように簡単に使用できます。

- name: Generate token
  id: generate_token
  uses: tibdex/github-app-token@v1
  with:
    app_id: ${{ secrets.APP_ID }}
    private_key: ${{ secrets.PRIVATE_KEY }}
    # Optional (defaults to ID of the repository's installation).
    # installation_id: 1337
    # Optional (defaults to the current repository).
    # repository: "owner/repo"
- name: Use token
  env:
    TOKEN: ${{ steps.generate_token.outputs.token }}
  run: |
    echo "The generated token is masked: ${TOKEN}"

CircleCIでの利用

CircleCI では簡単に扱う方法がないため、以下のスクリプトを使用します。

#!/usr/bin/env ruby
# frozen_string_literal: true

# GitHub Appsで使うアクセストークンを生成し、標準出力に表示するスクリプト。

# デフォルトgemではないjwtを入れる
require 'bundler/inline'
gemfile do
  source 'https://rubygems.org'
  gem 'jwt'
end

require 'openssl'
require 'net/http'
require 'json'
require 'jwt'

# 環境変数からAPP_ID, PRIVATE_KEYを読み込む。
gh_app_id = ENV['GITHUB_APPS_ID']
# Circleでは複数行の値を環境変数に使えないため、Base64でエンコードして設定
gh_private_pem_base64 = ENV['GITHUB_APPS_KEY_BASE64']
gh_private_pem = Base64.decode64(gh_private_pem_base64)

payload = {
  iat: Time.now.to_i - 60,
  exp: Time.now.to_i + (10 * 60),
  iss: gh_app_id
}
private_key = OpenSSL::PKey::RSA.new(gh_private_pem)
jwt = JWT.encode(payload, private_key, "RS256")

# httpリクエストを投げるのに必要な変数を用意
headers = { Authorization: "Bearer #{jwt}", Accept: "application/vnd.github.v3+json" }
http = Net::HTTP.new('api.github.com', 443).tap { |h| h.use_ssl = true }

# GitHubのAPIでアクセストークンを生成する
#
# - https://docs.github.com/en/rest/apps/apps#get-a-repository-installation-for-the-authenticated-app
# - https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app
installation = http.get("/repos/medpeer-dev/medpeer/installation", headers).then { |r| JSON.parse(r.body) }
access_token = http.post("/app/installations/#{installation["id"]}/access_tokens", {}.to_json, headers).then { |r| JSON.parse(r.body) }

# アクセストークンを出力
puts access_token["token"]

スクリプトの出力を環境変数に設定することで、プライベートリポジトリにアクセスできます。

- run:
    name: Set GitHub access token
    command: |
      export GITHUB_ACCESS_TOKEN="`./bin/gh_apps_token`"
      export BUNDLE_GITHUB__COM="x-access-token:${GITHUB_ACCESS_TOKEN}"

参考ページ


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

*1:全てのプロジェクトのリポジトリへのアクセス権限を持つことになってしまうため

*2:社内の方はSREプロジェクトの「依頼: GitHub Appsの委譲」のテンプレを参照してください。

Swift Concurrencyのキャンセルと向き合う

メドピアでモバイルアプリを開発している小林(@imk2o)です。 惑星直列に興奮しています。

Xcode13.2より、iOS13以降を対象にしたアプリであればSwift Concurrencyを利用できるようになりました。従来RxSwiftやCombineなどのReactive Streamを利用しているプロジェクトのSwift Concurrencyへの移行を進める際、考慮しなければならないことのひとつが「キャンセル」の扱いです。

以下に記載するコードとその挙動は、Swift5.6(Xcode13.4)とSwift5.7(Xcode14 beta)で確認しました。

Taskのキャンセルの仕組み

非同期処理を実行する Task オブジェクトは cancel() メソッドを持っており、これを明示的に呼び出すことでキャンセルができます。 Task がキャンセルされると通常 CancellationError が投げられますが、catchブロック内では Task.isCancelled の値をチェックする方法が推奨されています。キャンセルされた場合は直ちに return するほうがよいでしょう。

let task = Task {
  do {
    let result = try await someRequest()
  } catch {
    guard !Task.isCancelled else { return }
    // TODO: Handle error
  }
}

...

task.cancel()

Combineの AnyCancellable は破棄のタイミングで自動的にキャンセルされていましたが、 Taskcancel() を呼ばない限り、キャンセルされません。 Combineにおいては購読するストリームを Set<AnyCancellable> などで持っておき、ViewController(以降、VC)やViewModel(以降、VM)の破棄とともにキャンセルされるものと考えていましたが、Swift Concurrencyにおいては、

  1. VC/VMの破棄とともにキャンセルされる仕組みを導入する
  2. キャンセルを諦め、割り切る

のどちらで実装するかを考える必要があります。

Taskのキャンセル考慮は意外と険しい

VC/VMクラスのdeinitで cancel() すればよいかと思いきや、実はいくつかのハマりどころがあるのです。こんなViewModelクラスを考えます。

@MainActor
final class EchoViewModel {

    init(echoService: EchoService) {
        self.echoService = echoService
    }
    
    deinit { task?.cancel() }

    // Output
    @Published private(set) var echoBack = ""

    private let echoService: EchoService
    private var task: Task<Void, Never>?

    // 一定時間経過後にstringをエコーバックする
    func echo(_ string: String) {
        task = Task { [unowned self] in
            do {
                echoBack = try await echoService.echo(string)
            } catch {
                guard !Task.isCancelled else { return }
                dump(error)
            }
        }
    }
}

この echo("Hello!") を呼び出すと、一定時間後 echoBack プロパティが "Hello!" に変化するコードです。このときエコーバックされる前に画面を閉じてしまうと、VC/VMは直ちに破棄され、Task はキャンセルされるか...というとそうはなりません。

Task ブロック内は [unowned self] でVMの参照を持たないようにしているのですが、

echoBack = try await echoService.echo(string)

と書くことで、SwiftコンパイラがVMの参照カウントを増やす実行プログラムを生成してしまうのです。 従ってVCは破棄されてもVMは破棄されず、エコーバックされた後に破棄されるため、結果的にキャンセルが発生しません。

このコンパイラ仕様を回避するため、echo() メソッドを以下のように変えてみます。

    // EchoViewModel.swift
    func echo(_ string: String) {
        task = Task { [unowned self] in
            do {
                let echoBack = try await echoService.echo(string)
                self.echoBack = echoBack
            } catch {
                guard !Task.isCancelled else { return }
                dump(error)
            }
        }
    }

一度ローカル変数で受けることでコンパイラが生成する実行プログラムが変わり、この場合は画面を閉じるとVC/VMが破棄され、キャンセルが発動します。

実質的に同じ意味のコードを書いているのに挙動が変わるのは困りものですが、実はもっと危険なケースがあります。それは、 非同期処理がキャンセルに対応していない場合 です。

EchoServiceecho() メソッドがもし以下のような実装だとしたら、例えキャンセルしたとしても無視され、一定時間後にエコーバックしてしまいます。

final class EchoService {
    private let delay: TimeInterval = 3

    func echo(_ string: String) async throws -> String {
        // FIXME: withTaskCancellationHandlerでキャンセル対応する
        return try await withCheckedThrowingContinuation { continuation in
            DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
                continuation.resume(returning: string)
            }
        }
    }
}

このようにキャンセルが考慮されていない非同期メソッドを呼び出してしまうと、先ほどのVMのような実装にしたことで、クラッシュを発生させる要因を生み出してしまいます。 (self.echoBack = echoBack のところでクラッシュします)

この問題の対処法として、ここまでVMの echo() の中で Task を作り、同期/非同期の境界点としていましたが、VMの echo() を非同期メソッドにし、VC側で Task を作る形に変更してみます。

    // EchoViewModel.swift
    func echo(_ string: String) async {
        do {
            let echoBack = try await echoService.echo(string)
            self.echoBack = echoBack
        } catch {
            guard !Task.isCancelled else { return }
            dump(error)
        }
    }
final class EchoViewController: UIViewController {
    ...
    
    deinit { task?.cancel() }
    
    func sendEcho() {
        task = Task { [unowned self] in
            await viewModel.echo("Hello!")
        }
    }

この場合の画面を閉じたときの挙動は、VCは直ちに破棄されるので Task はキャンセルされます。VMはコンパイラによって参照カウントが増えており、少なくともVMの echo() メソッドのスコープにいる間はオブジェクトが生きているため self.echoBack = echoBack のところでもクラッシュしないので、致命的な問題は回避できそうです。

非同期処理がキャンセルに対応していない場合

非同期処理がキャンセルに対応している場合

まとめ

Swift Concurrencyにおいてはキャンセルは明示的に行う必要があること、またいくつかの留意点があることを紹介させていただきました。 ただ、「こんなに厄介ならキャンセルしない」と割り切るのもアリかなと思っています。

非同期処理の多くがUnaryなRest APIの呼び出しである場合、このリクエストをキャンセル可能にしたところで、通信や端末リソースの削減になるかというと微々たるもの...とも言えます。 Swift Concurrencyの設計思想からも、キャンセルはオプション的扱いで、必要であれば使ったらいいよという位置付けなんだろうなと思いました。

Combineと比較されがちですが、どちらにも長所・短所があると思うので適宜使い分けていくのが良いでしょう!


是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

テックサポート制度の用途をまとめてみました!

グループ戦略室でエンジニアのマネジメントをしている平川 a.k.a @arihh です。
リモートワークで酒量が0になりましたが体重は減らない日々です。

弊社にはテックサポート制度というものがあります。 テックサポート制度とは、1人あたり年間12万円までスキルアップ・生産性向上に関わるサポートを行う制度です。

制度導入から3年以上が経過し、申請された制度の数は1,000を超える数となりました。
今回は実際にどういうことに使われたのかをまとめてみました。

導入時のブログあるのでそちらもご覧いただけたらと思います。 tech.medpeer.co.jp

どんなものに使われていたの?

カテゴリーに分けた分類はこのようになりました!

金額での割合で分類を出していますが、件数でみると技術書が8割程度で圧巻でした。 技術書以外ではキーボード購入・ソフトウェアのライセンスと続いています。

メドピアで人気の技術書は?

技術書の本は紙の本でも電子書籍でもOKとなっています。
制度を使って買われた技術書ランキングは以下となりました!

毎週輪読会を行っているので、輪読会の対象となった技術書についてはランキングが上位になりました。

技術書以外で使われていたものは?

技術書以外で使われたものランキングTOP5は以下となりました!

  1. Happy Hacking Keyboard
  2. Dash
  3. IntelliJ IDEA All Product Pack
  4. RubyMine
  5. REALFORCE

技術書以外ではキーボードやIDEの用途が多かった中、Dashの人気が目立ちました。

他にもこんな使われ方も!

エンジニアのスキルアップ・生産性向上のために、いろいろな使われ方をしています。
いくつか気になった使われ方をピックアップしてみました!

AWSの学習のほか、RaspberryPiでクラスタを組むような使い方をしている人がいます。

さいごに

詳しいことが気になった方はカジュアル面談でお話できればと思っています! メドピアでは引き続きテックサポートのような制度でエンジニアへの投資を続けていきます!


是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

Private GitHub Pagesで社内ドキュメントを公開しよう!

集合知プラットフォーム事業部の榎本です。筋トレのお供のプロテインが切れそうなので、次に購入するプロテインのメーカーとフレーバーに悩んでます。

GitHub Pages でアクセス制限

今まで GitHub Pages というと静的サイトをインターネットへ全世界公開するしかできなかったのですが、2021年に Private GitHub Pages の機能がリリースされ、限定されたユーザーのみに制限してページを公開することが可能になりました

GitHub Pages を使って社内のドキュメントやナレッジを特定のユーザーだけに公開したり、企業内だけで共有したりすることができます。…(中略)… 今回の変更で、PrivateとInternalリポジトリでは、Private Pagesを使うことで、そのリポジトリを見れる人だけがそこから生成されるPagesのサイトを見られるという設定を行えるようになりました。

GitHub Pagesのアクセス管理 - GitHubブログ

今日は Private な GitHub Pages により社内ドキュメントをカジュアルに公開できるようになって捗りました、という話を紹介したいと思います。

Private GitHub Pages設定方法

Private GitHub Pages の設定方法は簡単です。下記のGitHub公式ドキュメント通りに進めれば OK です。

Changing the visibility of your GitHub Pages site - GitHub Docs

設定手順のサマリとしては下記の通りです。

  • リポジトリTOPへ
  • Settings (リポジトリ設定画面)へ
  • 左メニューから Pages を選択
  • GitHub Pages visibility で "Private" を選択

公開されるページのURL

下記のようなURLが GitHub 側で自動で生成され、 Private GitHub Pages としてアクセス可能になります。

https://xxx.pages.github.io/

一度発行されたURLは(設定を変えない限りは)基本的に変わらないようですので、安心して使えます。また、閲覧権限のない Private GitHub Pages にアクセスした場合は、 Unauthorized access error メッセージが表示されます。

(わかりやすい Custom Domain をページに割り当ててもよいかと思います。私のチームでは開発者向けの社内ドキュメントだしそのままでいいか、ということでGitHubから自動で割り当てられたURLをそのまま使用しました)

GitHub Pages の運用について

GitHub Pages の運用方法にはいくつか流派があります。

  • GitHub Pages 用のブランチ(gh-pagesブランチ)を用意してページを公開
  • main/master ブランチに docs ディレクトリを用意してページ公開

私のチームの場合、わざわざ公開用のブランチを用意して運用するのも手間がかかりそうだったので、後者の docs ディレクトリに html を放り込んで公開するかたちにしています。

Private GitHub Pages 設定例

Advanced な使用例として、peaceiris/actions-gh-pages みたいなものを使って、静的ページの生成および公開を自動化するのも良いでしょう。

使用例

実際の使用例を紹介したいと思います。私のチームでは下記のドキュメントを開発者向けドキュメントとして公開しています。

OpenAPI を redoc でhtml化した API ドキュメントを公開

redocで生成したAPIドキュメント

APIの仕様などについて話すときや、Pull Request からAPI仕様について言及するときなどに、GitHub Pages の URL で該当部分の仕様を参照できて便利でした。

Vue.js のコンポーネントをまとめた Storybook を公開

Vue.js コンポーネント Storybook

新しいページ追加のときに、既存のコンポーネントをStorybookのページから簡単に参照できて便利でした。

注意点

  • 無料プランでは Private GitHub Pages の機能は利用はできません
    • 個人利用であればGitHub Pro, Organization利用であればGitHub Team以上の契約が必要
  • Private GitHub Pages 閲覧のためには、GitHub アカウントで認証を通している関係上、GitHubユーザー登録およびOrganizationへの登録が必要
    • ドキュメントを全社員が閲覧できる形で公開するのは、GitHub の金銭コスト増、アカウント管理コスト増につながりなかなか辛いかも
    • コストを抑えたいなら開発者ドキュメントのみ Private GitHub Pages に公開すると良さそう
  • Private リポジトリであっても GitHub Pages の設定を Public にすると全世界公開されるので気をつけましょう

まとめ

Private GitHub Pages 登場前は静的サイトを社内だけに公開しようにも、自前で S3 を用意したり認証の仕組みを用意したりと、なかなか骨が折れることが多かったように思います。しかし Private GitHub Pages が登場したことで、社内向けのinternalな静的ページ・ドキュメントであればカジュアルに公開可能になりました。

みなさんも社内ドキュメントを Private GitHub Pages で公開して、ナイスな開発体験を手に入れてみてはいかがでしょうか。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

Feature Toggleを用いたRailsアプリの継続的なリリースと要注意事項

はじめに

皆様こんにちは、サーバーサイドエンジニアの草分です。

突然ですが、開発者の皆様、実装したソースコードはこまめにリリースしていますか? 「大きい機能なので開発に時間がかかる」「障害が起きないよう念入りにテストする必要がある」などの理由で、Featureブランチのままコミットグラフが伸びに伸びたりしていませんか?

大きな機能を作ること自体は悪いことではありませんが、大きすぎるFeatureブランチは、本流ブランチとの挙動の乖離やコードの衝突が発生しやすく、レビューやマージに多大な苦労を伴います。

この記事では、この問題の解決策の1つとなる「Feature Toggle」を、Ruby on Railsにおける実装方法と共にご紹介します。 Feature Toggle自体は開発手法の一種であるため、言語/フレームワークを問わず広く活用されています。

Feature Toggleとは

  • 「機能が動作する/動作しない を切り替える」機構です。
  • ソースコードは存在しますが、トグルを「ON」にするまで機能が動作しないよう制御します。

Feature Toggle 動作イメージ

効果

Featureブランチの早期マージが可能となる

通常、作りかけの機能を本流ブランチにマージしてしまうと、ユーザーに未完成の機能を提供することになるため、問題になってしまいます。

そこでFeature Toggleを用いて、開発中の機能はトグル「OFF」の場合動作しないように実装します。そうすることで、開発中の機能をユーザー環境に影響を出さずにマージできるようになります。

本番環境でのテストが可能となる

本番環境では、開発環境では見つからなかった問題が多々発生します。 例えば、既存データに予期せぬレコードが入っていたり、アプリではない別のレイヤーでトラブルが起きたり...

Feature Toggleで社内向けのテストユーザーのみ機能を利用できるよう制御すれば、実際のユーザー環境にリリースする前に、テストユーザーで機能の動作確認ができるようになります。

Railsでの実装例

この記事では、Feature Toggle実装の助けとなってくれる「Flipper」 gemをご紹介します。 このgemはフラグ管理と分岐制御の仕組みを提供しています。

github.com

Feature Toggleは独自に実装することもできますが、このgemを使えばフラグの動的切り替えやフラグの管理画面などを比較的簡単に導入することができます。

続きを読む

SwiftUI / UIKit (Storyboard) ハイブリッド対応、Needle + RIBs インスパイアな iOS アプリケーションデザイン

こんにちは、モバイルアプリを開発しています高橋です。交互に仕事場に猫二匹がやってきて監視されながら仕事しています。

先日リリースしたとある iOS アプリは、

  • 機能は機能ごとに分割して実装したい
  • 依存解決のコードは自動生成したい
  • ライトウェイトな設計としたい

というコンセプトの元、コンパイルセーフな DI フレームワークの uber/needle を使い、uber/RIBs のようなアプリケーションアーキテクチャでデザインすることで、各コンポーネントをコンパクトに分割することができました。

Needle や RIBs が前提知識となります。そのため本記事ではざっと Needle と RIBs を解説したのちに、具体的なコードを交えて SwiftUI + UIKit (Storyboard) ハイブリッド対応でかつ Needle + RIBs インスパイアなアプリケーションアーキテクチャの一端を解説してみます。

動作環境

  • Xcode 13.2
  • Swift 5.5
  • macOS 12 Monterey (macOS 11 BigSur でも確認済み)

Needle

まず、簡単に Needle を説明します。

Needle は各モジュールをコンポーネントとしてツリーで表現し DI することができるものです。

コードジェネレーターの cli ツール→①と、ジェネレートされたコードを引き回すライブラリ→②がセットになっており、①をビルド時に実行するようにして使います。

Swift Package Manager でも Carthage でも CocoaPods でも使うことができます。ここではサクッと①を Homebrew 、②を Xcode の Swift Package Manager で入れてみます。

①Needle コードジェネレータ

% brew install needle

次に、Build Phase で Compile Sources の前に以下を設定します。

mkdir "$TEMP_DIR"
touch "$TEMP_DIR/needle-lastrun"
SOURCEKIT_LOGGING=0 needle generate $SRCROOT/アプリフォルダ名/Needle.swift アプリフォルダ名

※Carthage で入れた場合はチェックアウトディレクトリの中にあるので、それ (Carthage/Checkouts/needle/Generator/bin/needle) を実行します。

ビルドしてみると、registerProviderFactories() という空のメソッドが生えている Needle.swift ファイルができます。このメソッドは後ほど使用します。

Needle.swift ファイルを .xcodeproj に入れたら、下準備完了です。

コラム: SOURCEKIT_LOGGING=0

SourceKit ロギングをサイレントモードにしています。 通常 Xcode はログを表示しますが、Xcode で作業している際のノイズを減らすために入れています。

②Needle ライブラリ

ライブラリの方もプロジェクトに入れます。Xcode → File → Add Packages... より Needle のレポジトリ(https://github.com/uber/needle.git)を指定して入れます。

Needle を Swift Package Manager で入れる
Needle を Swift Package Manager で入れる

Needle の使い方の簡単な説明をするとこのようになります。

  • コンポーネントをツリー構造にします。ルートだけ BootstrapComponent のサブクラスにします。
  • 親コンポーネントで定義したコンポーネントを Dependency にするので、上位コンポーネント(ルートとか)で諸々注入します。
  • 子コンポーネントでは親で注入されたものを protocol Dependency で定義することで利用することができます。
  • 子コンポーネントは Component<***Dependency> のサブクラスにします。

Needle を使ったサンプル

雰囲気を掴むために、雑ですがコードを示します。

RootComponent.swift

import NeedleFoundation

final class RootComponent: BootstrapComponent {
    var point: Int {
        return 100
    }
    var featureABuilder: FeatureABuilder { FeatureAComponent(parent: self) }
}

FeatureAComponent.swift

import NeedleFoundation

protocol FeatureADependency: Dependency {
    var point: Int { get }
}
protocol FeatureABuilder {
    func showPoint()
}
class FeatureAComponent: Component<FeatureADependency> {
}
extension FeatureAComponent: FeatureABuilder {
    func showPoint() {
        print(dependency.point)
    }
}

これで子の FeatureAComponent の中で、親の RootComponent で定義した point にアクセスすることができています。Component<***Dependency> の中で dependency というプロパティが生えているので、これ経由でアクセスすることができます。

  • 注:***Builder という protocol を登場させています。 Component の protocol として定義しています。(後述)

AppDelegate あたりで先ほど触れた registerProviderFactories() を呼び出すことで Needle との接続を行います。

AppDelegate.swift

import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    private(set) var rootComponent: RootComponent!
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        registerProviderFactories() // Needle.swift で定義されている
        rootComponent = RootComponent()
        return true
    }
    // MARK: UISceneSession Lifecycle
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {}
}

RootComponent を経由して featureAComponent を取り出し、そのメソッドを呼び出して挙動を確認します。

SceneDelegate.swift

import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let _ = (scene as? UIWindowScene) else { return }
        let rootComponent = (UIApplication.shared.delegate as! AppDelegate).rootComponent
        let featureABuilder = rootComponent!.featureABuilder
        featureABuilder.showMoney()
    }
}

実行結果:

100

雰囲気だけで申し訳ありませんが、 Needle は以上のような感じで (point が Component になった時とか、孫 Component を持った時なども同様に) 自動で DI することができます。

RIBs

今回のプロジェクトはあくまでインスパイアされているだけなので実際は使用していませんが、軽くどういうものが触れておくと、こちらは VIPER や MVC のようなアプリケーションアーキテクチャであり、フレームワークでもあります。

実際に使用する際は、uber/RIBs に則ってプロジェクトにインストールします。Xcode テンプレートも利用することができ、スクリプトを実行して Xcode にインストールすることで新規コンポーネントを作成するときに自動で必要なファイルが生成されるようになっているため、効率的に開発が進められるとのことです。

RIB

RIBs は Router-Interactor-Builder(RIB) が中心となったデザインになっており、RIB 単位でコンポーネントとして分割します。画面であればこれに View を付け足します。

  • Router: RIB 間のやりとりを担当し、画面系の RIB であれば画面遷移なども担当します
  • Interactor: ビジネスロジックを担当します
  • Builder: Router / Interactor / View を作ります
  • View: データを表示したり、ユーザーインタラクションを担当します

出典: https://github.com/uber/RIBs/wiki/iOS-Tutorial-1

ルール

大枠のルールとしてはこのような感じです。

  • 別 RIB にアクセスする場合は、Router から別 RIB の Builder を参照する
  • 直接 View から Router にアクセスせず、Interactor で Router のメソッドへアクセスしてもらう

今回のアプリケーションアーキテクチャ(Needle + RIBs インスパイア)

ようやく本題です。

前述した Needle を使用し、RIBs の設計とルールを取り入れたアプリケーションアーキテクチャデザインになっており、Needle + RIBs インスパイアとこの記事では命名しております。(現場では特に名前をつけていません)

Needle + RIBs inspired App Architecture Example
Needle + RIBs inspired App Architecture Example

ベース部分の実装イメージ

あくまでイメージですが、RootComponent.swift や SceneDelegate.swift は以下のようになっています。

RootComponent.swift

import NeedleFoundation

final class RootComponent: BootstrapComponent {
    // DI
    var apiClient: APIClient { APIClient() }
    var errorLogger: ErrorLogger { ErrorLogger() }
    
    // 子コンポーネント
    var featureABuilder: FeatureABuilder { FeatureAComponent(parent: self) }
    var featureBBuilder: FeatureBBuilder { FeatureBComponent(parent: self) }
}

SceneDelegate.swift

import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
        guard let rootComponent = appDelegate.rootComponent else { return }
        let featureABuilder = rootComponent.featureABuilder
        let window = UIWindow(windowScene: windowScene)
        self.window = window
        window.rootViewController = featureABuilder.viewController
        window.makeKeyAndVisible()
    }
}

AppDelegate.swift

import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    private(set) var rootComponent: RootComponent!
    func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        registerProviderFactories()
        rootComponent = RootComponent()
        return true
    }
    // MARK: UISceneSession Lifecycle
    func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
        UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
    func application(_: UIApplication,
                     didDiscardSceneSessions _: Set<UISceneSession>) {}
}

(Needle の紹介をしていたときに、 Builder という protocol を急に登場させていましたが)RIBs で言うところの Builder として Component は振る舞ってもらう、という発想に基づいて Builder と命名し、これを使って初期の画面を作成しています。

このとき、Builder はよく build() と命名されているメソッドではなく、エンドポイントの内容を示す意味で viewController を持つようにしています。

コンポーネント部分の実装イメージ

実際のコンポーネントは Builder、Interactor、Router (、View) で構成するので、コンポーネントごとにそれらの protocol と実装を作成し、ファイル分割します。(今回は雑ですが、コンポーネントごとにファイル分割して以下に掲載してしまいます)

FeatureA コンポーネントの View (SwiftUI) から FeatureB コンポーネントの View (UIKit) を画面遷移するサンプルを作ってみました。

ちなみに Interactor は今回は Router へのただの橋渡し役になっています。(API 通信やデータベースアクセスなどデータ処理・ドメインロジックが入るときはここで対応する)

FeatureAComponent.swift

import NeedleFoundation
import UIKit
import SwiftUI

// MARK: -
// MARK: Builder

protocol FeatureADependency: Dependency {
    // 説明用にここで注入。ただしこのサンプルであれば、FeatureAComponent の中で生成してもよい
    var featureBBuilder: FeatureBBuilder { get }
}
protocol FeatureABuilder {
    var viewController: UIViewController { get }
}
final class FeatureAComponent: Component<FeatureADependency> {}
extension FeatureAComponent: FeatureABuilder {
    var viewController: UIViewController {
        let navigationController = UINavigationController()
        let router = FeatureARouter(viewController: navigationController,
                                    featureBBuilder: dependency.featureBBuilder)
        let interactor = FeatureAInteractor(router: router)
        let viewController = FeatureAViewController(interactor: interactor)
        navigationController.viewControllers = [viewController]
        return navigationController
    }
}

// MARK: -
// MARK: Router

protocol FeatureARouting {
    func showFeatureB()
}
final class FeatureARouter: FeatureARouting {
    private weak var viewController: UIViewController?
    private let featureBBuilder: FeatureBBuilder
    init(viewController: UIViewController, featureBBuilder: FeatureBBuilder) {
        self.viewController = viewController
        self.featureBBuilder = featureBBuilder
    }

    func showFeatureB() {
        guard let viewController = viewController else { return }
        viewController.present(featureBBuilder.viewController, animated: true)
    }
}

// MARK: -
// MARK: Interactor

protocol FeatureAInteracting {
    func showFeatureB()
}
final class FeatureAInteractor: FeatureAInteracting {
    private let router: FeatureARouting
    init(router: FeatureARouting) {
        self.router = router
    }
    func showFeatureB() {
        router.showFeatureB()
    }
}

// MARK: -
// MARK: View

final class FeatureAViewController: UIHostingController<FeatureAView> {
    private let interactor: FeatureAInteracting
    init(interactor: FeatureAInteracting) {
        let view = FeatureAView(interactor: interactor)
        self.interactor = interactor
        super.init(rootView: view)
    }
    @MainActor @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
struct FeatureAView: View {
    let interactor: FeatureAInteracting?
    var body: some View {
        Button {
            guard let interactor = self.interactor else { return }
            interactor.showFeatureB()
        } label: {
            Text("B 画面開けるかな 🤔")
        }.padding()
    }
}

FeatureB では View を Storyboard で作成しています。Storyboard の説明は割愛します。

FeatureBComponent.swift

import NeedleFoundation
import UIKit

// MARK: -
// MARK: Builder

protocol FeatureBDependency: Dependency {
}
protocol FeatureBBuilder {
    var viewController: UIViewController { get }
}
final class FeatureBComponent: Component<FeatureBDependency> {
}
extension FeatureBComponent: FeatureBBuilder {
    var viewController: UIViewController {
        let router = FeatureBRouter()
        let interactor = FeatureBInteractor(router: router)
        let storyboard = UIStoryboard(name: "FeatureB", bundle: nil)
        guard let viewController = storyboard.instantiateInitialViewController() as? FeatureBViewController else { return UIViewController() }
        viewController.interactor = interactor
        router.viewController = viewController
        return viewController
    }
}

// MARK: -
// MARK: Router

protocol FeatureBRouting {
    func dismiss()
}
final class FeatureBRouter: FeatureBRouting {
    weak var viewController: UIViewController?
    init() {
    }
    func dismiss() {
        viewController?.dismiss(animated: true)
    }
}

// MARK: -
// MARK: Interactor

protocol FeatureBInteracting {
    func dismiss()
}
final class FeatureBInteractor: FeatureBInteracting {
    private let router: FeatureBRouting
    init(router: FeatureBRouting) {
        self.router = router
    }
    func dismiss() {
        router.dismiss()
    }
}

// MARK: -
// MARK: View

final class FeatureBViewController: UIViewController {
    var interactor: FeatureBInteracting?
    @IBAction func dismissButtonWasTapped(_ sender: UIButton) {
        interactor?.dismiss()
    }
}

Demo

Needle で SwiftUI の画面から UIKit (storyboard) の画面に遷移するデモの動画キャプチャ

SwiftUI 画面 → UIKit 画面への画面遷移ができました!

Demo ソースコード一式:

github.com

ちなみに

  • データモデル (Entity) などはこれらと別に struct を定義し Needle の管理下とは関係なくあちこちで利用します。

まとめ

  • ライトウェイトなアーキテクチャである
  • コンパイルセーフでバシバシ DI できる
  • SwiftUI も UIKit でも柔軟に対応できる

よかったらお試しください!


是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 iOS / Android のモバイルエンジニアも募集しています。カジュアル面談からでも OK です! ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html