DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

タクシーアプリ「GO」Android版へ自動テストを導入するまでの道のり

こんにちは、Androidチームの田熊(fgfgtkm)と外山(sumio)です。SWETのAndroidチームでは、Androidのプロダクトに対して自動テストのサポートをしています。

この度株式会社Mobility Technologiesが提供するタクシーアプリ「GO」のAndroid版に対する、おおよそ2年間に渡る取り組みが終了しました。

本記事では、この取り組みで行ってきた次の2点を紹介したいと思います。

  • 「GO」のAndorid版に対してどのような自動テストを導入したか
  • 開発チームに自動テストを定着させるまでにやったこと

タクシーアプリ「GO」に対しての自動テスト導入

SWETのAndroidチームは「社内のAndroidエンジニアが自動テストを書くことを習慣化している」ことをゴールの1つとして、自動テストを普及させるための取り組みをしています。 その中でAndroidのテストナレッジの社内展開を目的としたハンズオンを実施してきました。

しかし、社内の多くのチームはテストが書きにくい設計になっているという課題を持っており、既存機能にテストを書くためには設計を改善する必要がありました。(参考:テストの社内普及のための取り組みとして、Androidテストハンズオンを実施しました

今回紹介する「GO」(旧サービス名:MOV)も例外ではありません。 ハンズオンではカバーしきれない現場の課題を解決して自動テスト導入を促進したい、そして「GO」をそのモデルケースにしたい、と考えたのが本取り組みのきっかけです。

それから約2年間、開発チームと協力しながら次の施策を実施しました。

  • コード改善 + ユニットテストの導入
  • スクリーンショットテスト

それぞれどのようなことをやってきたか、具体的にお話したいと思います。

コード改善 + ユニットテストの導入

当時の「GO」は歴史的経緯から一貫したアーキテクチャが定められておらず、多くの処理がFragmentやActivityに記述されていました。メイン機能となる地図を表示している画面(以降、地図Fragmentと呼びます)も同様で、配車に関連する複雑なロジックが地図Fragmentに実装されていました。

最初に行ったことは、この地図Fragmentに実装されたロジックを別クラスに切り出した上でユニットテストを書けるようにし、MVVMの構成にすることでした。以降、地図Fragmentや関連する画面クラスの改善を地道に進めていきました。

画面から切り出したほうが良い処理をピックアップし、画面からの分離⇔ユニットテストを実装する。そのサイクルを繰り返すことで、少しずつユニットテストが書けるコードを増やしていきました。 

こうしてテストコードが追加された結果、そのテストコードを参考に開発メンバー自身が自発的にユニットテストを実装してくれるようになりました。(具体的な数値については「自動テストを書くことを習慣化できたか?」のセクションで紹介します)

また、もともと開発チームでもMVVMアーキテクチャにしていきたいという思いがあったため、開発チーム主導でMVVM化がどんどん進んでいき、今ではほとんど全ての機能がMVVMアーキテクチャで実装されています。

改善した内容の一部はMOV Android版に対する「コード改善+テスト導入」の取り組みの紹介でもまとめていますので、是非ご参照ください。

スクリーンショットテスト

ユニットテスト導入をある程度進めたタイミングで、一度リリース前検証で出た不具合チケットを整理しました。その結果、画面まで結合させないと発見が難しい不具合、ViewModel以下のレイヤのユニットテストでは発見の難しい不具合が高い割合で起票されていることがわかりました。

Androidアプリ開発においてこのような不具合はつきもので、問題を早期発見するためにUIテストを導入できないか考えました。そしてUIテスト実装のアイディアについて開発チームと議論を重ねた結果、スクリーンショットを活用したテスト(以降、スクリーンショットテストと呼びます)を導入することで合意しました。

SWETではスクリーンショットテストを導入するために、主に次の2つのことを実施しました。

  • スクリーンショットテストを書きやすくするための基盤を整備する
  • スクリーンショット一覧レポートの確認や画像差分の比較ができるようにCI/CDを整備

スクリーンショットテストを書きやすくするための基盤

スクリーンショットテストは画面が任意の状態になるようにデータをセットし、さらに非同期処理やレンダリング完了まで待った上でスクリーンショットを撮る必要があります。ユニットテストと比較するとテストコード側でセットアップする必要のあることが多くなり、ボイラープレートが増えてしまいます。

我々はできるだけスクリーンショットテストを書く大変さを軽減したいと考え、書きやすくするための基盤の実装に着手しました。そして開発チームと一緒に様々なパターンのスクリーンショットテストを実装しながら、機能をブラッシュアップしていきました。

この基盤は次のことを実現しています。

  • Junit5のExtensionとして提供され、テストに適用するだけで「GO」で安定してスクリーンショットテストを取得するために必要なセットアップが完了する(非同期処理の待ち合わせ設定など)
  • Activity・Fragment・DialogFragmentそれぞれの起動を簡潔に書けるようにする手段の提供
  • 画面単位・View単位など様々なスクリーンショット取得方法の提供
  • テストでよく使用されるEspressoのコードを汎用化

基盤の実装の一部はGOGO Screenshot Test for AndroidとしてOSS化しています。是非ご参照ください。

CI/CDの整備

取得したスクリーンショットは一覧化したレポートを作成することで、エンジニア以外のメンバーでもデザインのレビューがしやすくなります。 また、reg-suitといったツールを使えば、Visual Regression Testにも活用できます。 例えば、前回リリース時点のスクリーンショットと現在のバージョンのスクリーンショットを比較すれば、意図せぬUIの変更が含まれていないか検証できます。

Andorid スクリーンショットテスト 3つのプロダクトに導入する中で倒してきた課題より引用)

これらのことを実現するために、次のようなワークフローをBitriseを使って整備しました。このワークフローはマージやタグ追加のタイミングで実行されます。

  • Instrumentation Testを実行し、スクリーンショットを取得する
  • 取得したスクリーンショットの一覧レポートを作成する
  • 任意のバージョン間のスクリーンショットを比較してreg-suitのレポートを作成する

あわせて、不必要な差分が含まれないスクリーンショットを安定して取得できるように基盤の改善をしました。

スクリーンショットテストの導入および、スクリーンショットを安定して取るためにやったことはAndorid スクリーンショットテスト 3つのプロダクトに導入する中で倒してきた課題にもまとめています。是非こちらもご参照ください。

自動テストを書くことを習慣化できたか?

冒頭でSWETのAndroidチームは「社内のAndroidエンジニアが自動テストを書くことを習慣化している」ことをゴールの1つとしていると述べました。 それでは自動テスト導入の取り組みの結果、「GO」の開発チームにおいて自動テストを書くことを習慣化できたのでしょうか。

最近のリリース時点での自動テスト件数とカバレッジの推移は次のようになっています。

まずはLocal Testの推移です。Local Testには主に画面以外のユニットテストが含まれます。

次に、スクリーンショットテストが含まれるInstrumentation Testの推移です。スクリーンショットテストを導入したバージョン5.3.0から集計しています。

ユニットテスト・スクリーンショットテストともに各バージョンで増加しています。また、これらのテストはほとんどSWETメンバーによる実装ではなく、開発メンバー自身が実装したものです。取り組みを始めた当初はユニットテストが十数件ほどでしたが、現在ではコンスタントに自動テストが増えています。

この結果から、「GO」の開発チームにおいては自動テストを書くことを習慣化できたと考えています。

自動テストの習慣化が実現できたのは、開発チームの尽力によるところが大きいです。一方でSWETからも自動テストが定着するような働きかけを行ってきました。

次のセクションでは、自動テストを定着させるためにどのような取り組みを行ってきたかを紹介します。

自動テストがチームに根付くまで

導入したテストが効果を発揮するためには、テストの継続的運用が不可欠です。 そのためには、開発チーム自身がテストの追加・修正といったメンテナンスをし続ける必要があります。

  • テストを導入したものの、導入した担当者が異動を機にメンテナンスされなくなってしまった
  • テストを書く気持ちはあるけれども、ついついプロダクトの実装を優先してしまう

といった話は色々なところで耳にします。 開発チームのメンバーひとりひとりがテストを書けるようになり、更にその状態を継続するのは大変なことです。

幸い、「GO」開発チームでは自動テストを書くことを習慣化できました。 自らテストを書く習慣は「こうすれば身に付く」といった答えのあるものではありませんが、一例として「GO」開発チームにおける習慣化のためにSWETが働きかけてきたことを紹介します。

ユニットテスト

「コード改善 + ユニットテストの導入」で触れたようなテスタビリティ改善の取り組みによって、開発メンバーの書いたテストが少しずつ増えるようになりました。

この段階になってから重要なのがトラブル発生時のサポートです。 特に次のようなトラブルは、一歩間違うと未解決のまま放置されてしまうことになりがちです。

  • モックライブラリMockkで意図通りにモックできず、テストが失敗してしまう
  • CI動かしたときだけ、時々テストが失敗してしまう

失敗したテストが残ったままの状態だと「テストをオールグリーンの状態に保つ」という気持ちが折れてしまい、テストがメンテナンスされず放置されるきっかけになってしまいます。 そうなってしまわないように、Slackに常駐して、テスト関連のトラブルに気付いたときには積極的にサポートするようにしました。

現在では、このようなトラブルも開発メンバー自身で解決し、SWETのサポート無しでテストの追加・修正を継続できています。

スクリーンショットテスト

一方スクリーンショットでは、先に触れたようなスクリーンショットテスト基盤やワークフローの整備だけでは、開発チームが自らテストを追加するまでには至りませんでした。 そこからテストを書く習慣が根付くまでの道のりは試行錯誤の連続でした。

モブプロでテストを書くハードルを下げる

当時は、新たに構築したテスト基盤の使い方とテストの書き方の説明会を実施し、その後のテストの追加は開発チームに任せようという考え方でいました。 ところが、説明会終了後もテストが増えないままだったため、テストを書くハードルをもっと下げる必要があるのではないか、と考えました。

そこで、更にテストを書くハードルを下げるために、とある画面のスクリーンショットを撮るテストをモブ・プログラミング(以降モブプロと表記します)で実装してみることにしました。
モブプロの役割分担は次の通りです。

  • ドライバー(プログラムを書く人):SWET
    • テスト基盤の使い方などを説明しながらコーディングしました
  • ナビゲーター(ドライバーに指示する人):開発メンバー
    • スクリーンショット対象画面を表示するために必要なデータのセットアップ方法を案内しました

このモブプロで、「シンプルなコードで目的の画面のスクリーンショットを撮る」方法が開発チームに伝わったという手応えがありました。

モブプロで開発メンバーにコーディングしてもらう

その後新機能のリリースが立て続けに発生し、長い間テストが追加されない状態でした。

大きなリリースが終わり、開発チームが一息ついた頃に「そろそろスクリーンショットテスト書いてみませんか」と呼び掛けてみたところ、 「前回のモブプロでは自分が手を動かさなかったので書き始められる自信がない」という意見を多数いただきました。

確かに、前回のモブプロでは主にSWETがドライバーをしていたため、開発メンバーがコードを書く機会はほとんど有りませんでした。 そこで、開発メンバーをドライバーに据えて、改めてモブプロを開催することにしました。

  • ドライバー:開発メンバー1名
  • ナビゲーター:SWETと、残りの開発メンバー
    • SWETはテスト基盤の使い方を案内しました
    • 残りの開発メンバーは、狙った画面を表示するための方法を案内しました

このモブプロではスクリーンショット撮影に必要な一通りのコードを全員に書いて欲しかったため、開発メンバー全員がドライバーを担当するまで毎週開催することにしました。 その甲斐あって、スクリーンショットテストが追加されるようになりました!
・・・が、しばらく経つとテストが追加されない状態に戻ってしまいました。

短期的なゴールを共有する

そうこうしている内に、私達が支援できる期限は残り3か月を切ってしまいました。 当初はテストで撮影したスクリーンショットをデザイナーさんにレビューしてもらう計画だったにもかかわらず、肝心のテストが増えないのでレビューしてもらう画像も無いという状況でした。

この状況を打開するため、開発チームに次のような提案をしてみました。

  • 次回リリースのデザインレビューでは、テストで撮ったスクリーンショットをデザイナーさんにレビューしてもらうようにしたい
  • デザインレビューまでに、次回リリースで追加・修正される画面だけで良いからスクリーンショットを撮れるようにしたい
  • スクリーンショットテストを1人で書くのはまだ不安があると思うので、引き続きモブプロで書けるだけ書いていきたい

有り難いことに、開発メンバーの皆さんに賛成してもらえたので、3巡目となるモププロを開催しました。 今回のモブプロは役割分担こそ2巡目と同じものの、次の点が異なっていました。

  • テスト完成の期限が明確になった
  • スクリーンショットを撮る対象画面の範囲が明確になった

期限と範囲が明確なため、モブプロだけではカバーしきれなかった画面のテストが次々と追加されていきました。 モブプロだけでは必要なスクリーンショットが撮れそうになかったため、デザインレビューの期限に間に合うよう頑張ってもらえたのだと思っています。

また、提案したときは意図していなかったのですが、対象に難易度の高い画面が含まれていた点が良い方向に作用しました。 モブプロで難易度の高い画面に取り組んだことで、意図通りの状態で対象画面(Fragment)を起動するスキルが格段に向上したのです。 合わせて、難易度の高い画面に対応するための改善を、テスト基盤に多数取り込むことができました。

スキルが向上してスクリーンショットテストを書く手間が減っていくと、デザインレビュー以外の方法でも便利に使えるという声が開発メンバーから聞かれるようになりました。

  • Pull Requestを出すときにスクリーンショットを貼り付けるのが楽になった
  • 複雑な操作をしないと出せない画面を確認するのが楽になった

最終的には、デザインレビュー当日に必要なスクリーンショットテストが全て揃い、色々な画面サイズ・解像度でのスクリーンショットをデザイナーさんに渡すことができました。 開発メンバーの皆さんには、私達が離脱した後も継続してスクリーンショットテストを活用してもらえています。

まとめ

「GO」のAndroid版にユニットテストとスクリーンショットテストを導入し、開発チームが自動テストを書く習慣が根付くまでに取り組んだ内容を紹介しました。

今回の取り組みを通して、次のような気付きを得ることができました。

  • ユニットテストのようなシンプルなテストは、最初に参考となるテストを追加することで他のメンバーも書き始めやすくなる
  • 一方で、スクリーンショットテストのような複雑なテストを書いてもらうには、基盤の導入だけでは不十分
  • テストの書き方を身につけてもらうには、当人がドライバーとなってモブプロを行うのが効果的
  • 目標や期限が決まるとモチベーションがアップする

その一方で、次のような幸運に恵まれた点も多かったと思います。

  • 開発メンバーの皆さんが、自発的に自動テスト実装やアーキテクチャの改善をしてくれたこと
  • 開発メンバーの皆さんが、デバッグが難しい複雑なトラブルでも解決できる高いスキルを持っていたこと
  • 開発メンバーの皆さんがいつも協力的だったこと
  • 私達の支援期間が終了するまでに何とかしたいという気持ちを全員が持っていたこと

他のチームで、ラッキーな面を差し引いたとしてもなおこの方法が通用するのかは分かりませんが、今回の経験で得られた学びはとても大きなものでした。 本記事が、皆さんがテスト導入に取り組むときのヒントになれば幸いです。

謝辞

Mobility Technologiesの「GO」Android版開発チームの皆さんには、本取り組みのために貴重な時間を割いていただきました。 また、UIテスト基盤のOSS化について快諾していただきました。改めて感謝いたします。

Unityプロジェクト向けRoslynアナライザの作りかた

SWETグループの長谷川(@nowsprinting)です。

Unity 2020.2以降、Unityエディタ上でRoslynアナライザによる静的解析 (static analysis) を実行可能になりました。 また、それ以前のバージョンで作られたUnityプロジェクトであっても、JetBrains RiderなどのC#向けIDE(統合開発環境)上でRoslynアナライザの実行がサポートされています。

静的解析を充実させることで、コンパイラだけではチェックしきれないようなバグや性能劣化の原因を早期に検出できます。 例えば弊社では、実行時に動的にインスタンス化されるクラスのコンストラクタがIL2CPPビルド時にストリップされないように [Preserve]アトリビュートの指定漏れを検出するアナライザを導入し、ショーストッパーとなりえる問題を早期発見できるようにしています。

通常、こうした問題の検出はシニアエンジニアによるコードレビューに頼られます。静的解析によってレビュー負荷を軽減できれば、より高度な問題に目を向けられるという副次的効果も見込めます。

本記事では、社内フレームワークやプロジェクト固有のルールに対応するカスタムアナライザを作る手順やTipsを紹介します。

Roslynアナライザとは

Roslyn(ろずりん)とは、C# 6.0から導入された.NETコンパイラプラットフォームの通称です。C#およびVisual Basic向けコンパイラのほか、コード生成API、コード解析APIが公開されています。

Roslynのコード解析APIでは、構文解析 (syntactic analysis) および意味解析 (semantic analysis) を行なうことができ、これを利用して様々なアナライザを自作できます。 これを、本稿ではRoslynアナライザもしくは単にアナライザと呼びます。

なお、一般的なUnityプロジェクトで利用できるオープンソースのアナライザもあり、例えば以下のものが知られています。

UnityプロジェクトでRoslynアナライザを使用する

Roslynアナライザの作り方に先立って、Unityプロジェクトでアナライザを使用する方法について述べます。

Unity 2020.2以降、Unityプロジェクト内のアナライザ(および依存する)DLLファイルを適切に設定することで、Unityエディタ上で静的解析が実行されるようになりました。しかし、実用するにはまだ使い勝手が良いとは言えません *1 *2 *3 *4 *5 *6 *7

一方で、多くの方がコーディングに使用されているC#向けIDE(具体的にはJetBrains Rider、Visual Studio、Visual Studio Code)では、プロジェクトの.csprojファイルに使用するアナライザを定義してあれば静的解析が実行されます。 アナライザによる診断がもっとも欲しいタイミングは、まさにIDEでコードを書いているときです。アナライザを導入するのであれば、IDEでの診断をメインに据えることをおすすめします。

まずUnityプロジェクトでのDLL設定を、続いて、その設定を.csprojに反映する方法を紹介します。

UnityプロジェクトでのDLL設定

Unityプロジェクト(2019.4で確認しています)に配置したDLLファイルはPlugin Inspectorウィンドウで設定を変更でき、その設定はDLLの.metaファイルに書き込まれます。

Plugin Inspectorウィンドウ

ProjectウィンドウでAssetsフォルダ下にあるアナライザのDLLを選択してPlugin Inspectorウィンドウを表示し、以下のように変更します。

  • Select platform for plugin下のチェックをすべてoff
  • Asset Labelsに RoslynAnalyzer を追加 *8

DLLがAssetsフォルダ下にない場合、Asset Labelsは設定できませんので注意してください。

アナライザ設定を.csprojファイルへ反映する

IDEが使用する.csprojファイルは、IDEに対応したプラグインパッケージ *9 によって自動生成されます。 以下のプラグインでは、前述のようにUnity 2020.2向けに設定されたアナライザDLLの設定を.csprojに反映してくれますので、Unityプロジェクト側で前述の設定を済ませるだけでIDEでも静的解析が実行されるようになります。

上記以外のIDEをお使いの場合、また、AdditionalFilesを使用するアナライザを使用する場合は、.csproj生成時にアナライザの定義を挿入する必要があります。 この設定をサポートしてくれるエディタ拡張がCysharpさんにより公開されていますので、こちらを利用してみてください。

github.com

重要度設定とサプレス設定

アナライザには、診断項目ごとにデフォルトの重要度 (severity) が設定されています。プロジェクトによって重要度を上げたい/下げたい場合、これを上書き設定できます。

UnityエディタおよびVisual Studioの場合、ルールセットファイルを使用します。設定方法は公式マニュアルの Roslyn analyzers and ruleset files を参照してください。 Riderの場合は、Preferences... を開き、Editor | Inspection Settings | Roslyn Analyzersの中で設定できます。

また、特定のコードにおいてのみ、診断の対象外にしたいケースもあります。

  • 対象となるクラスやメソッド定義に対して [SuppressMessage]アトリビュートをつけることで、そのクラス/メソッド内では指定した診断をサプレスできます
  • 対象となるコード行の前後に #pragma warning disable <DiagnosticID>#pragma warning restore <DiagnosticID> ディレクティブを書くことで、指定した診断をサプレスできます

なお、Riderにはインスペクションを行単位でサプレスできるコメント書式がありますが、Rider上であってもRoslynアナライザの診断に対しては無効です。 #pragma warning disable/restore ディレクティブを使用してください。

Roslynアナライザの作成(基礎編)

続いて、基本的なRoslynアナライザ作成の手順を紹介します。

プロジェクトの準備

Roslynアナライザは、.NETプロジェクトとして作成します。 .NET SDK コマンドラインツールのほか、.NETプロジェクトを扱うことのできるIDEが使用できます。

ただし、アナライザ用のプロジェクトテンプレートは、Windows版のVisual Studioでのみ提供されています。 ここではVisual Studio 2019でプロジェクトを作成する手順を紹介します *11

ひな形の作成

  1. Visual Studio Installerを起動し、インストール済みVisual Studio 2019 *12 の「変更」をクリック。「個別のコンポーネント」タブにある「.NET Compiler Platform SDK」を選択してインストールします
  2. Visual Studio 2019を起動し、「新しいプロジェクトの作成」をクリック。プロジェクトテンプレートとして「Analyzer with Code Fix (.NET Standard)」のC#のほうを選択します(同じ名称でVisual Basic向けもあるので注意)

これでアナライザのひな形ができました。 ひな形のソリューションは、例えば名称を HogeFugaAnalyzer としたとき、下記5つのプロジェクト (.csproj) で構成されています。

  • HogeFugaAnalyzer: アナライザ本体。この名称はソリューションと同じものです
  • HogeFugaAnalyzer.CodeFixes: アナライザで検出した問題のオートフィックス機能を提供するプロジェクトです。本記事では解説しません
  • HogeFugaAnalyzer.Package: アナライザ本体とCode FixをNuGetパッケージ (.nupkg) ファイルにパッケージングするプロジェクトです。Unityプロジェクト専用のアナライザではDLLファイルを直接扱うため使用しません
  • HogeFugaAnalyzer.Test: アナライザのユニットテストを記述するプロジェクトです
  • HogeFugaAnalyzer.Vsix: Visual Studio拡張機能としてアナライザをデバッグ実行するためのプロジェクトです。本記事では解説しません *13

Windows以外の環境でビルドする設定

以降の開発をWindows以外の環境で行なう場合、ひな形のままではビルドできないため、アナライザ本体の PackageID 属性を書き換えます。

HogeFugaAnalyzer.csprojを任意のエディタで開き、

<PackageId>*$(MSBuildProjectFullPath)*</PackageId>

の部分を、例えば

<PackageId>HogeFugaAnalyzer</PackageId>

に変更します。 ただし、この名称はNuGetパッケージ (.nupkg) の名前としてパッケージングプロジェクト (HogeFugaAnalyzer.Package.csproj) 内で定義されています。同時にHogeFugaAnalyzer.Packageプロジェクトは削除しましょう。

もしNuGetパッケージとして配布を予定しているのであれば、パッケージングにまつわる設定をアナライザ本体の.csprojに記述するか、もしくは本体のPackageIDを例えば下記のように変更することで回避できます。

<PackageId>HogeFugaAnalyzer.Diagnostic</PackageId>

テストプロジェクトの設定

生成されたひな形ではテストプロジェクトが.NET Core App 2.0向けに設定されているため、開発環境に合わせて変更します。

例えば.NET SDK 5.0で開発する場合、 HogeFugaAnalyzer.Test.csprojをエディタで開き、

<TargetFramework>netcoreapp2.0</TargetFramework>

の部分を

<TargetFramework>netcoreapp5.0</TargetFramework>

に変更します。

以上で、IDE上でビルドが成功する状態になります。

アナライザの動作解説

アナライザのひな形プロジェクトでは、コード上にあらわれる型の名称を検査し、小文字が含まれていれば警告するサンプルが実装されています。 これをもとに、簡単に診断の流れを解説します。

DiagnosticAnalyzer

アナライザは、DiagnosticAnalyzer を継承かつ [DiagnosticAnalyzer]アトリビュートがつけられたクラスとして定義されます。

DiagnosticAnalyzerを継承したクラスには、SupportedDiagnosticsプロパティおよび Initializeメソッドの実装が必要です。

SupportedDiagnostics

このアナライザが提供する診断内容 (DiagnosticDescriptor) を配列で返します。

DiagnosticDescriptor は、IDEのインスペクション設定で一覧表示され、重要度 (severity) を設定させるために使われます。 また、実際に問題を検出した際に表示されるメッセージもここに設定します。

ひな形では private static readonly DiagnosticDescriptor Rule を定義し、これを ImmutableArray でラップして返す実装になっています。

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
{
    get
    {
        return ImmutableArray.Create(Rule);
    }
}
Initialize

Roslynコンパイラによってアナライザがロードされると呼ばれるメソッドです。

コンパイラは、ソースコードに対して、字句解析・構文解析・意味解析といったフェーズを経て中間言語 (Intermediate Language) を出力します。 アナライザは、その診断内容に適したフェーズでコンパイラからコールバックを受けて動作します。 Initializeメソッドでは、コールバックを受け取りたい契機を AnalysisContext に登録します。

ひな形では、シンボルの意味解析完了ごとに動作させたいメソッド AnalyzeSymbol() を、 RegisterSymbolAction() で登録しています。

public override void Initialize(AnalysisContext context)
{
    (snip)
    context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
}

アクションには、シンボルのほかにもコンパイルの開始・終了、メソッド呼び出しなど多数のアクションが定義されており、自由に組合わせてアナライザを作ることができます。

ただし、各アクションの呼び出し順は保証されていません *14。たとえばsymbol actionの時点でsyntax tree actionの処理が完了していることに依存するようなアナライザは動作しない恐れがあります。

アクションに関する詳細は、Roslynのドキュメント Analyzer Actions Semantics を参照してください。

診断の実行

コールバックを受けるよう登録した private static void AnalyzeSymbol() が実際の診断を行なうメソッドです。 診断に必要な情報は引数で受け取れます。ここではシンボルの名前に小文字を含む場合、診断結果である Diagnostic を生成して返しています。

private static void AnalyzeSymbol(SymbolAnalysisContext context)
{
    var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;

    if (namedTypeSymbol.Name.ToCharArray().Any(char.IsLower))
    {
        var diagnostic = Diagnostic.Create(
          Rule,
          namedTypeSymbol.Locations[0],
          namedTypeSymbol.Name);

        context.ReportDiagnostic(diagnostic);
    }
}

Diagnostic.Create() の引数は、DiagnosticDescriptorLocation、メッセージ挿入語句となっています。 この結果を受けたIDE上では Location で指示された位置にポップアップ等で DiagnosticDescriptor に定義されたメッセージが表示されます。

Riderでの診断結果表示例

以上のように、アナライザプロジェクトのひな形は、そのままでアナライザとして動作するものが生成されています。 アナライザの開発がはじめてであれば、この時点で一度Unityプロジェクトに組み込んでその振る舞いを確認することをおすすめします。

アナライザのテスト

ユニットテスト

アナライザ作成においても、できるだけ早い段階かつ小さい単位でユニットテストを行なうことは役に立ちます。 しかし、Roslynコード解析APIで提供されているテストAPIは、解析対象コードと期待する Diagnostic を引数に取って合否検証まで行なうという粒度の大きいものしかありません。 このAPIでは、複雑な診断を行なうアナライザのテストは困難です。

そこで、アナライザの実行と結果検証を分けて使えるフレームワークDena.CodeAnalysis.Testingを作成し、使用しています。 Dena.CodeAnalysis.Testingは、GitHubおよびNuGet Galleryで公開しています。

github.com

www.nuget.org

Dena.CodeAnalysis.Testingを使用するには、テストプロジェクトの.csprojに下記の定義を追加します。

<PackageReference Include="Dena.CodeAnalysis.Testing" Version="1.0.0" />

Dena.CodeAnalysis.Testingを用いたテストの書きかた

テストは次のように記述できます。

var analyzer = new YourAnalyzer();

var diagnostics = await DiagnosticAnalyzerRunner.Run(
    analyzer,
    @"
public static class Foo
{
    public static void Bar()
    {
        System.Console.WriteLine(""Hello, World!"");
    }
}");

var actual = diagnostics
    .Where(x => x.Id != "CS1591") // Ignore "Missing XML comment for publicly visible type or member"
    .ToArray();

Assert.AreEqual(1, actual.Length);
Assert.AreEqual("YourAnalyzer0001", actual.First().Id);

この例では DiagnosticAnalyzerRunner.Run にアナライザのインスタンスと検証対象コードを渡し、診断結果を受け取った後に Assert で期待通りの結果であるかを検証しています。

アナライザの実行と Assert が分離されているため、最終的な診断結果だけでなく、アナライザ内の中間情報を検証するテストを書くこともできます。 また、アナライザをテストダブル(モック、スタブ、スパイ等)に置き換えてのテストも可能です。

ユニットテストを考えるとき、例えば INamedTypeSymbol などをテストデータとして生成できればよいのですが、Roslyn APIでは困難です。そのため上記のように診断対象コードをテストの入力データとしています。

最後の Assert はテスト対象や目的に応じて様々な書きかたがありますが、ひとつ注意すべき点があります。 上例では診断結果から CS1591 を取り除いた上で、件数および DIagnostic.Id を検証しています。 もしこれを直接

Assert.IsTrue(diagnostics.Any(x => x.Id == "YourAnalyzer0001"));

のように書いてしまうと、想定外の診断結果バグを見逃してしまったり(上例では2つ目の Diagnostic が無いことを確認できません)、返るはずの Diagnostic が返らなかったとき原因判明に時間がかかったりします(テストデータのミスによるコンパイルエラーは多々発生します)。 少々面倒に感じますが、関係ないものを明示的に取り除いてから Assert する手順を踏むことをおすすめします。

LocationAssert.HaveTheSpan

Dena.CodeAnalysis.Testingでは、補助的なツールとして LocationAssert.HaveTheSpan も提供しています。 次のように、 DiagnosticResult に含まれる Location が正しく設定されていることの検証を簡単に記述できます。

LocationAssert.HaveTheSpan(
    new LinePosition(7, 34),  // 期待する始点
    new LinePosition(7, 42),  // 期待する終点
    diagnostic.Location
);

なお、Location の行およびカラムはコードでは0オリジンで指定しますが、診断メッセージには1オリジンで(つまり+1して)表示されます。 1足すか引くかは混乱しがちなポイントですが、 LocationAssert.HaveTheSpan では次のようにアサートメッセージに両方の数値が並べて表示され、判別しやすくなっています。 また、メッセージはそのままテストコードのexpectedとしてコピー&ペーストできる書式となっています。

LocationAssert.HaveTheSpanの差分表示例

テストデータについてのTips

ユニットテストの効率を高めるため、テストで使用するデータ(アナライザにおいては診断対象コード)の質には気を配るべきです。 特に、実際の検証対象コードと乖離したデータでテストを書いてそれがパスしてしまうと、アナライザを実際のプロジェクトへと組み込んでから問題に気づくこととなり手戻りが大きくなります。

そのため、よほどシンプルなものを除き、テストデータは string ではなく個別のテキストファイルに定義することをおすすめします。 拡張子を.csにすることで、コンパイルエラーや警告をあらかじめ修正でき、テスト実行時に自作アナライザとは無関係の診断結果に煩わされることもなくなります。

複数のテストデータで同名のクラスを使用している場合、ファイルを小分けすることで使いまわしができます。逆に同名のクラスをあえて使い分けたい場合、テストケースごとに namespace を使い分けることで実現できます。

その他、以下の点にも注意してみてください。

  • メソッド呼び出しを検出するとき、それが拡張メソッドでないか。拡張メソッドは明示的に型が異なるため、テストデータも拡張メソッドとして記述しなければ検出できません
  • アトリビュートのクラス名に Attribute を付け忘れていないか。例えば、アトリビュートのクラス名が HogeAttribute でも Hoge でも、[Hoge] と記述できてしまいます。しかし、アナライザから見ると別物です

Unityプロジェクトに組み込んでのテストTips

アナライザをDLLにビルドし、それをUnityプロジェクトに組み込んでテストする際のTipsを紹介します。

極力ユニットテストで品質を上げておく

Unityプロジェクトに組み込んでからのテストではデバッグしづらく、また修正・再実行に時間もかかります。 大前提として、極力、ユニットテストでリアリティの高いテストデータ(診断対象コード)を使って品質を上げておくべきです。

dotnet buildコマンドを使用する

IDEでアナライザを実行する場合、IDEによってアナライザ自体がキャッシュされるため、何度も安定したテストを実行することは困難です。 そのため、初期の段階ではdotnetコマンドでインクリメンタルコンパイルを無効にした実行を試すことをおすすめします。

次のコマンドで実行できます。

$ dotnet build --no-incremental YOUR_PROJECT_NAME.csproj

ファイルロガーを使用する

アナライザをUnityプロジェクトに組み込むと、コンソール出力を観測できないため、いわゆるprintデバッグは不可能になります。 そのため、デバッグ困難な事象に突き当たってしまったら、早めにファイルロガーの仕組みを入れることをおすすめします。

なお、OSSなど外部のロガーソリューションを利用する場合、依存するDLLもすべてアナライザ本体同様 <Analyzer> ノードに書く必要がある点に注意してください。

Roslynアナライザの作成(応用編)

実用的なアナライザとして、冒頭で触れた「実行時に動的にインスタンス化されるクラスのコンストラクタがIL2CPPビルド時にストリップされないように [Preserve] アトリビュートの指定漏れを検出するアナライザ」の作成方法を紹介します。

このアナライザの実現には、二段階の処理が必要です。 まず、メソッドの呼び出し箇所でコールバックを受け、それが特定のメソッド呼び出しであるかを判断し型引数を取得します。 続いて、その型のコンストラクタに [Preserve] アトリビュートが定義されてるかを診断します *15

では、順に見ていきましょう。まず Initialize でメソッド呼び出し箇所のコールバックを登録します。

public override void Initialize(AnalysisContext context)
{
    context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
    context.EnableConcurrentExecution();

    context.RegisterOperationAction(
        AnalyzeAttributes,
        OperationKind.Invocation
    );
}

コールバックに渡される OperationAnalysisContext からは、呼び出しているメソッドの情報が取得できます。 ここでは、クラスを型引数として取り、実行時に動的にインスタンス化するクラスを登録しているメソッド呼び出しを検出します。

private void AnalyzeAttributes(OperationAnalysisContext context)
{
    var invocation = (IInvocationOperation)context.Operation;
    var methodSymbol = invocation.TargetMethod;

    if (methodSymbol.ContainingType.Name != "ServiceCollection")
        return;

    if (methodSymbol.Name != "AddSingleton")
        return;

    if (methodSymbol.TypeArguments.Count()==0)
        return;

特定のメソッド呼び出し(ここでは ServiceCollection.AddSingleton<>())であったならば、続いて型引数のコンストラクタについているアトリビュートを診断します。

    var injectedClass = methodSymbol.TypeArguments[0] as INamedTypeSymbol;
    var ctorShouldBePreserved = new List<IMethodSymbol>();

    foreach (var ctor in injectedClass.Constructors)
    {
        foreach (var attribute in ctor.GetAttributes())
        {
            if (attribute.AttributeClass?.Name == "PreserveAttribute")
            {
                ctorShouldBePreserved.Add(ctor);
            }
        }
    }

以上で、ServiceCollection.AddSingleton<>() に型引数として渡しているクラスのコンストラクタに [Preserve] アトリビュートがついているか否かを確認できました。

最後に、診断結果をコンパイラに渡します。

    if (ctorShouldBePreserved.Count == 0)
    {
        var diagnostic = Diagnostic.Create(
          CtorDoesNotHaveInject,  // [Preserve]付きコンストラクタが存在しない意のDiagnosticDescriptor
          location,               // 割愛していますが、呼び出し部分の型引数部分を指すLocation
          injectedClass.Name);
        context.ReportDiagnostic(diagnostic);
    }
}

コードでは割愛していますが、この診断結果の Location (primary location) には、本来の問題箇所である型引数のコンストラクタ定義箇所ではなく ServiceCollection.AddSingleton<>() 呼び出し位置(の型引数部分)を指定しています。

IDE上でアナライザが実行されるときはエディタタブで開かれているファイルを起点とした診断しか行われないため *16 *17、このアナライザは呼び出し側のファイルでしか機能せず、またそのファイル以外を指す診断結果は無効となるためです。

Preserveアトリビュートの指定漏れを検出するアナライザの診断例

まとめ

なかなか情報も少なく、ハードルが高い印象のRoslynアナライザです。しかし、導入してしまえばエンジニアへの負担なく問題を早期発見できる、とても効果の高いものです。

プロジェクトのコーディング規約やレビューでの観点のうち明文化されているものがあれば、それをアナライザにはできないか、検討してみてはいかがでしょうか。

最後に。以上のような活動に共感していただけた方、興味を持たれた方、一緒に働いてみようと思ってくれた方。 下記職種で採用しておりますので、ぜひご応募ください。お待ちしております。

https://career.dena.jp/job.phtml?job_code=1618career.dena.jp

*1:診断結果は、GUIではコンソールウィンドウ、CLI (Batch mode) ではビルドログに混ざって出力されます

*2:Unity 2020.2では、診断は当該ファイルのコンパイルもしくはReimportの契機でのみ実行されます(Unity 2020.3.4より、通常のコンパイルステップで動作するように修正されました)

*3:Unity 2020.2では、ルールセットファイルによる重要度の設定変更は、Reimportの契機でのみ反映されます(Unity 2021.1で修正されました)

*4:Unity 2020.2では、CLI実行ではルールセットファイルによる重要度設定は無効です(Unity 2021.1で修正されました)

*5:Packages/下のDLLには、アナライザとして識別させるためのラベル設定ができません

*6:Packages/下にアナライザとして設定したDLL(と.metaファイル)を配置しても、アナライザとして動作しません

*7:その他、Unity 2020.2時点のRoslynアナライザサポート状況はこちらにまとめてあります https://www.nowsprinting.com/entry/2021/04/18/200619

*8:ラベルは、ウィンドウ右下のしおり状アイコンをクリックすることで入力できます。アイコンが表示されていない場合は"Asset Labels"の文字をクリックすると表示されます

*9:Package Managerウィンドウでインポートおよびアップデートできます

*10:Visual Studio Code (VSCode) 用プラグインです。Visual Studio用ではないのでご注意ください

*11:その他の環境でアナライザプロジェクトを作成する場合は、 Roslyn SDK内にあるテンプレート https://github.com/dotnet/roslyn-sdk/tree/main/src/VisualStudio.Roslyn.SDK/Roslyn.SDK/ProjectTemplates/CSharp/Diagnostic もしくはテンプレートリポジトリ https://github.com/nowsprinting/RoslynAnalyzerTemplate を利用するか、こちらの記事を参考に設定してください https://zenn.dev/naminodarie/articles/32973a36fcbe99

*12:Community EditionでもRoslynアナライザは作成できますが、for macではできません

*13:Visual Studio 2019 v16.10ではデバッグ実行の手段が変わるため不要になるようです。参考 https://qiita.com/ryuix/items/36dabbf3c7e4e395e49e

*14:compilation start/endのような対になっているものの呼び出し順は保証されます。また、compilation end actionは必ず最後に1回実行されます

*15:あからじめSymbolActionでコンストラクタのアトリビュートを収集しておき、後でメソッド呼び出し箇所のコールバックで処理する、という二段構えの方法も考えられますが、2つの理由で採用していません。1つ目は、アクションの呼び出し順が未定義であること。2つ目は、IDE上で実行されるときはエディタタブで開かれているファイルを起点とした診断しか行われないため、型引数に使われている型が別ファイルにあるとSymbolActionが呼ばれないためです

*16:インスペクション機能はプロジェクト全体を診断してくれますが、この場合も実行単位はファイルごとのため同様の制限があります

*17:先に紹介したdotnet buildコマンドではこの制限を受けないため、診断されているはずなのにIDEに表示されないときはdotnet buildコマンドを試してみると原因にたどり着きやすいこともあります

Jenkins Shared Librariesの活用事例の紹介

1. はじめに

SWETグループの井口です(@hisa9chi)です。現在はスマホ向けゲーム開発案件にてゲーム開発者がゲーム開発に集中できるようにCI/CD関連を幅広くサポートしています。 本稿では、その中でも Jenkins Pipeline Job で利用可能な Shared Libraries に関して弊社でどのように活用しているか事例を紹介してみたいと思います。

Jenkinsと聞くとおそらく皆さんは、昔は利用していたが今は運用コストが高いなどの理由から、マネージドなクラウドのCI/CDサービスへ移行したという方が多いのではないでしょうか。しかし、ゲーム開発の現場ではJenkins master / agentのクラスタ構成を構築して、運用を続けているプロジェクトが弊社内にも多く存在します。なぜ、運用コストが高いにもかかわらず構築して運用しているかというと、ゲーム開発特有の理由からです。

ゲーム開発において、修正後にビルドして実機で動作やレイアウトなどを確認するサイクルはとても重要です。このサイクルが短ければ短いほどゲームの修正確認がスムーズに行え、ゲーム開発者はゲーム開発に集中できると思います。しかし、ゲーム開発では大容量のデータを扱うことが多く、開発が進むにつれてビルド時間が増加する傾向にあります。そのため、ビルド時間を少しでも短くするために、高スペックな物理マシンを手元に用意してJenkins master / agentのクラスタ構成を構築して運用しています。

この運用コストに関しては本稿では触れませんが、SWETとして取り組んでいることをCEDEC 2020にて発表しております。発表資料は以下にありますので、そちらも参考にしていただければ幸いです。

CEDEC 2020「モバイルゲーム開発におけるJenkinsクラウド時代のJenkins構築と管理テクニック」

2. 前提

本稿では以下を前提としております。

3. Jenkins Shared Libraries

3.1. 概要

Jenkinsのジョブ作成にてPipeline Jobが多く利用されるようになると、共通処理が複数のPipeline Jobで出現することがあります。この共通処理をPipeline Jobとは切り離して定義できれば、Pipeline Jobのメンテナンス性向上や共通処理等の再利用性も向上します。この共通処理等をPipeline Jobの外部に定義して、Pipeline Jobごとにロードして利用する仕組みが、Shared Librariesです。

3.2. 特徴

Shared Librariesの特徴として以下があります。

  1. ジョブ毎に異なるバージョンの利用が可能
    ライブラリをSCM管理しているため、ブランチ/タグ/コミットハッシュ指定でジョブごとに異なるバージョンのライブラリをロードして利用可能です。

  2. メンテナンス性と再利用性の向上
    共通処理等をPipeline Jobの外に定義しているため、変更はライブラリ内に閉じることが可能です。また、Jenkins masterが異なる場合でも、ライブラリが管理されているリポジトリへアクセス可能であれば容易に利用可能です。他にも、Pluginの利用をラップしたライブラリを作成しておくことで、Pluginの更新に伴う変更もライブラリ内に閉じることが可能な場合もあります。

  3. classメソッドのapproveが不要
    Pipeline Job内でgroovyを用いてclassメソッド1を利用した処理を定義していた場合、そのclassメソッド毎にapproveが必要となる場合があります。Jenksinfile内のgroovyコードで多くのclassメソッドを利用していてapproveされていない場合、このapprove作業は非常に面倒な作業となります。しかし、Shared Librariesであればapproveする必要がないためそのような面倒な作業は発生しません。

3.3. 作成

基本的にはライブラリ群を決められたディレクトリ構造で作成して、そのリポジトリをSCM管理します。そのリポジトリをJenkins側に設定してジョブごとでライブラリを読み込むように設定することで、ジョブごとのワークスペースにライブラリ群がチェックアウトされて利用可能になります。

先に示した公式のドキュメントに詳細は記載されていますが、簡潔に説明するとディレクトリ構造として大きく以下となります。

  • src
    • パッケージに分けて独自のクラスを定義可能
  • var
    • pipeline jobで利用可能な変数定義(.groovy)とヘルプファイル(.txt)
    • xxx.groovyのファイル名を変数として利用可能でありそのファイル内に定義されているメソッドを {ファイル名}.{メソッド名} で呼び出し可能
  • resources
    • groovyではないファイル(xxx.jsonやxxx.sh等)を格納
    • libraryResourceを利用してメソッド内で利用が可能

src/var/resourcesに関しての実装サンプルは公式のサンプルがわかりやすいのでそちらを確認してください。

3.4. 利用

作成したShared Librariesを利用する場合は以下の2ヵ所に設定が必要となります。

  1. Jenkinsのシステム設定
  2. ジョブごとにライブラリをインポート

3.4.1. Jenkinsのシステム設定

Pipeline Jobにてロード可能なライブラリをGlobal Pipeline Librariesへ設定します。JenkinsからSCM経由でライブラリにアクセス可能であることが前提となります。

設定箇所は以下のように 「Jenkinsの管理」-「システムの設定」内の Global Pipeline Libraries の項目になります。

f:id:swet-blog:20201217214645p:plain

項目 設定内容 設定例
Name ライブラリ名。ジョブでインポートする際に利用 mylibrary
Default version ブランチ名、タグやコミットハッシュを指定 master
Retrieval method 利用するSCM Modern SCM
Source Code Management 利用するSCMサービス Git
プロジェクトリポジトリ リポジトリURL SSH or HTTPSアクセス用URL
認証情報 リポジトリへの認証情報 credentialsに登録済みの物を指定

3.4.2. ジョブごとにライブラリをインポート

基本的には、Pipeline Job内で @Library を活用して先ほど設定したライブラリを読み込みます。

// ライブラリの読み込み
// デフォルトバージョンの読み込み
// "Global Pipeline Libraries 設定" の "Default version"
@Library( 'ライブラリ名' ) _

// ブランチやタグを指定する場合
@Library( 'ライブラリ名@{branch名 | tag名}' ) _

// 複数ライブラリの読み込み
@Library( ['ライブラリ名1', 'ライブラリ名2'] ) _

注意が必要なのは最後の _ を忘れないことです。これがなければエラーとなるので注意してください。 _ を記載することでライブラリ側に定義した、var 配下のスクリプトが利用可能になます。利用には var 配下のファイル名をそのまま変数名として利用できます。

また、src などで独自に定義したclassの利用に関してはそのクラスをimportする必要があります。

// srcで定義した class を利用する場合
// 利用したい class を import する必要あり
@Library( 'ライブラリ名' ) import org.foo.Sample

// 利用時は script { } ブロック内で利用する
script {
  def sample = new org.foo.Sample()
  sample.hello()   // Sample 内に定義されている hello() メソッドの呼び出し
}

srcで定義されているclassを利用する場合は script {} ブロックに入れる必要があることに注意してください。

4. 活用事例

では、実際に弊社でどのように利用しているか実際の以下の3つの事例を紹介させていただきます。

  1. 成果物等のGCSアップロード
  2. ジョブ終了時に自動メンション(Slack通知)
  3. ジョブ失敗時のstage名の取得

4.1. 成果物等のGCSアップロード

ジョブを実行した際の成果物やログなどはarchiveArtifacts Pluginを活用してJenkins masterに保存しておくことが一般的かと思います。しかし、ジョブの成果物はサイズが大きい物などもありJenkins masterのディスク容量を圧迫してしまいます。この対策として、ジョブごとにビルド履歴の保存件数を制限するbuildDiscarderを設定する方法があります。ただし、以下の問題があります。

  • ジョブ数が多い場合は結果ディスクフルになってしまいジョブが正しく動作しない
  • ビルド履歴を制限すると過去の成果物の取得ができない

これらを解決する施策として、クラウドのストレージであるGCSやS3にアップロードして保存しておくことが挙げられます。今回はGCSへのアップロードを例にShared Libraryの利用事例をご紹介します。

GCSへのアップロードにはGoogle Cloud Storage Pluginを利用することで簡単に行えます。ただ、アップロードの際には対象のバケット配下に {ジョブ名}/{ビルド番号} のディレクトリを作成してアップロードしたいという要望があります。Pluginをそのまま活用するとアップロード先にバケット名だけでなく {バケット名}/{ジョブ名}/{ビルド番号} とジョブ毎に指定する必要があります。バケット名に関してはジョブ毎に変更する可能性はありますが、ジョブ名ビルド番号は共通してJenkinsの環境変数から取得して指定します。この共通部分をジョブごとに記載するのは煩わしいためライブラリ化しています。

// var/gUploadArtifactsToGCS.groovy
#!/usr/bin/env groovy

// Google Cloud Storage Uplaod(for Google Cloud Storage Plugin)
def call( Map params = [:] )
{
  // 必須パラメータチェック
  if ( params.bucket == null ) {
    println "gUploadArtifactsToGCS: 'bucket' param is not specified."
    return
  }
  if ( params.credentialsId == null ) {
    println "gUploadArtifactsToGCS: 'credentialsId' param is not specified."
    return
  }
  if ( params.pattern == null ) {
    println "gUploadArtifactsToGCS: 'pattern' param is not specified." 
    return
  }

  def uploadDir = "gs://${params.bucket}/${env.JOB_NAME}/${env.BUILD_NUMBER}"
  
  // Google Cloud Storage Plugin の提供メソッド
  googleStorageUpload( bucket: uploadDir, credentialsId: params.credentialsId, pattern: params.pattern )
}
// Jenkinsfile
@Library( 'mylibrary' ) _

pipeline {
  agent { label 'master' }
  stages {
    stage ( 'sample' ) {
      steps {
        sh 'touch sample.txt'
        gUploadArtifactsToGCS( bucket: ARTIFACTS_BUCKET_NAME, credentialsId: GCS_CREDENTIAL_ID, pattern: 'sample.txt' )
      }
    }
  }
}

上記のようにShared Libraryを呼び出すことで指定したバケット ARTIFACTS_BUCKET_NAME 配下に {ジョブ名}/{ビルド番号}/sample.txt としてアップロードされます。

他にも以下のような情報をGCSへアップロードするようなライブラリを作成して利用しています。

  • ジョブ実行時に指定したビルドパラメータとビルドログ
    Jenkinsにて定義済みのclassを活用してファイルに出力してGCSへアップロード

  • ジョブを実行したビルドマシンの環境情報
    agentのOSやツールのバージョンを取得するスクリプトをShared Library側に登録してそれを呼び出して情報を収集してGCSへアップロード

4.2. ジョブ終了時に自動メンション(Slack通知)

ジョブが終了した際に結果をSlackなどへ通知していることは多いかと思います。弊社でもJenkinsのジョブを手動でトリガーした場合やcronで実行された際の結果をSlackへ通知(Slack Notification Pluginを利用しています)しています。しかし、ジョブも多く頻繁に実行されると通知の数が増えるため、Slackの通知が埋もれてしまい気づけない状況が発生するため、特定の人へメンションしたいというケースがあります。

あるプロジェクトでは、ジョブ毎にメンション先をビルドパラメータ化して実行時に設定してもらうという運用がなされていました。しかし、この方法は利用側からすると面倒であり指定を間違えてしまうこともあります。そのため、ジョブをトリガーしたユーザの情報から自動でそのユーザにメンション付きでSlack通知するようにライブラリを作成して利用しています。

仕組みを簡単に説明すると、トリガーしたユーザのメールアドレス(Slackに登録しているアドレスと同一)を元に、SlackのIDを検索してそのユーザへメンションするための文字列である <@UserID> を返却しています。2

// var/gGetSlackUsersMentionString.groovy
import java.util.concurrent.TimeUnit
import groovy.json.*

@Grab( group='com.squareup.okhttp', module='okhttp', version='2.7.5' )
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;

def call( Map params = [:] )
{
  // 必須パラメータチェック
  if ( params.userEmails == null ) {
    println "gGetSlackUsersMentionString: 'userEmails' param is not specified."
    return
  }
  // 必須パラメータチェック
  if ( params.slackAPIToken == null ) {
    println "gGetSlackUsersMentionString: 'slackAPIToken' param is not specified."
    return
  }
  
  String slackApiUrl = 'https://slack.com/api/'
  String apiMethod = 'users.list'
  String query = 'token=' + params.slackAPIToken;
  String requestUrl = slackApiUrl + apiMethod + '?' + query

  Request request = new Request.Builder()
                          .url( requestUrl )
                          .header( 'User-Agent', 'jenkins' )
                          .get()
                          .build();
  
  OkHttpClient client = new OkHttpClient();
  client.setConnectTimeout( 5, TimeUnit.MINUTES );    // connect timeout
  client.setReadTimeout( 5, TimeUnit.MINUTES );       // socket timeout

  // Response
  Response response = client.newCall( request ).execute();

  // json へ変換
  Object json = new JsonSlurper().parseText( response.body().string() )
  
  // User の mention 用 ID 文字列
  String mentionIdsStr = '';

  // email から User ID を検出
  for ( user in json.members ) {
    for ( target in params.userEmails ) {
      if ( user.profile.email == target ) {
        mentionIdsStr += '<@' + user.id + '> ' 
      }
    }
  }

  return mentionIdsStr;
}
// Jenkinsfile
@Library( 'mylibrary' ) _

pipeline {
  agent { label 'master' }
  stages {
    stage ( 'sample' ) {
      steps {
        script {
          def mentionStr = gGetSlackUsersMentionString( userEmails: [ EMAIL_ADDRESS ], slackAPIToken: SLACK_API_TOKEN )
          // Slack Notification Plugin の提供メソッド
          slackSend( color: 'good', message: "${mentionStr}\n Message from Jenkins Pipeline" )
        }
      }
    }
  }
}

上記のように利用することで、ジョブをトリガーしたユーザへのメンション付きでSlack通知することを可能にしています。

f:id:swet-blog:20201217214705p:plain

4.3. ジョブ失敗時のstage名の取得

こちらもSlackに通知する際の便利機能の1つになります。Jenkins Pipeline Jobでは複数のstageが定義されています。ジョブが失敗した際のSlack通知にて、どのstageで失敗したかも補足情報として通知されているとエラー箇所の確認などに役立ちます。しかし、エラーとなったstageを特定するのは以下のように面倒であり、Jenkinsfileへのコード量も増えてしまいます。

// Jenkinsfile
pipeline {
  agent { label 'master' }
  stages {
    stage( 'sample' ) {
      ....
    }
    post {
      // 失敗となった場合に stage 名称を環境変数に設定
      failure {
        script {
          env.ERROR_STAGE='sample'
        }
      }
      // ここまでがエラー stage 名称の設定
    }
  }
  // Declarative: Post Actions
  post {
    failure {
      // Slack Notification Plugin の提供メソッド
      slackSend( color: 'danger', message: "ErrorStage: ${env.ERROR_STAGE}" )
    }
  }
}

上記のように全てのstageの post { failure { script {} } } ブロックにて env.ERROR_STAGE='ステージ名' というような設定を記載する必要があります。これはかなり面倒であり、多くのメンバーでメンテナンスをしていると記載を忘れてしまうこともあります。 そのため、最後の Declarative: Post Actions でfailureの際にエラーとなったstageを取得できれば余計なコードもなくなりスマートになります。Pipeline Jobの環境変数などでエラーstage情報が提供されていないため、Jenkinsのクラスからstageを取得する以下のライブラリを作成して利用しています。

// vars/gGetFailedStageName.groovy
#!/usr/bin/env groovy

import hudson.model.Run;
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
import org.jenkinsci.plugins.workflow.graphanalysis.ForkScanner;
import org.jenkinsci.plugins.workflow.pipelinegraphanalysis.StageChunkFinder;
import com.cloudbees.workflow.rest.external.StageNodeExt;
import com.cloudbees.workflow.rest.external.StatusExt;
import com.cloudbees.workflow.rest.external.ChunkVisitor;

def call() {
  String errorStageName = '';

  WorkflowRun workFlowRun = currentBuild.getRawBuild();
  ChunkVisitor visitor = new ChunkVisitor( workFlowRun );

  // stage 情報の取得
  ForkScanner.visitSimpleChunks( workFlowRun.getExecution().getCurrentHeads(), visitor, new StageChunkFinder() );

  // 全ての Stage のステータスを検索
  for ( StageNodeExt stageExt : visitor.getStages() ) {
    if ( stageExt.getStatus() == StatusExt.FAILED ) {
      errorStageName = stageExt.getName();
      break;
    }
  }

  return errorStageName;
}
// Jenkinsfile
@Library( 'mylibrary' ) _

pipeline {
  agent { label 'master' }
  stages {
    stage ( 'sample' ) {
      ....
      error '強制的にエラー'
      // 以降の stage はスキップ stage のステータスはエラー状態
    }
  }
  // Declarative: Post Actions
  post {
    failure {
      // Slack Notification Plugin の提供メソッド
      slackSend( color: 'danger', message: "ErrorStage: ${gGetFailedStageName()}" )
    }
  }
}

上記のように利用することでエラーとなったstage名をSlackのメッセージ内に入れることが可能になります。

f:id:swet-blog:20201217214649p:plain

ただし、先頭から確認して最初にエラーとなったstage名のみ(上記のサンプルでは "sample" stage名のみ)を返却するようになっています。基本的には最初にエラーとなったstage以降のstageはスキップされ失敗した状態となっています。そのため、最初にエラーとなったstage名だけを返却する方針で良いと判断したためこのような仕組みとなっております。

5. 終わりに

今回は弊社で利用しているJenkins Piepline JobにおけるShared Librariesの利用事例を紹介しました。今回紹介したのは一部であり、Shared Librariesは独自クラスを定義してJenkins Pipeline Jobで利用が可能になるなど他にも多くの利用方法が挙げられます。Shared Librariesに関しては私たちも日々どのように利用するのが良いか色々と模索しています。ですので、自分たちも利用しているという方がおられましたら、これを機会にどのように活用しているかなどのノウハウや情報をお互いに交換できればと思っています。 もし、ざっくばらんに話してみたいなどありましたら井口(@hisa9chi)までご連絡いただければと思います。


  1. 例えばユーザID情報を取得のメソッドであるhudson.model.Cause$UserIdCause getUserId()など

  2. Shared Libraryを使ってSlackのUserIDを取得していますが、現在ではPluginでemailからSlackのUserIdを検索できるようになっています。slackUserIdFromEmail

大規模リポジトリで高速にgit cloneするテクニック

ニッチな話題ですが、業務におけるCI/CDの現場では避けることのできない大規模リポジトリと戦うためのgit cloneのテクニックを紹介します。

この記事はDeNA Advent Calendar 2020の10日目の記事です。
CI/CDマニアの@Kesin11です。SWETではCI/CDチームの一員として、CI/CDの啓蒙活動やJenkinsを必要とするチームのサポートなどの業務を行っています。

はじめに

おそらくどこの会社でも1つぐらいは巨大なリポジトリが存在しているかと思いますが、歴史あるリポジトリはgit cloneするだけで数分を要し、checkout後のリポジトリサイズがGB単位になることも珍しくないでしょう。業務で古くから存在するプロジェクトのリポジトリを触ったことがある方はきっと経験があるかと思います。

git cloneを実行するのは最初のセットアップ時だけなのであまり問題にならないと思われるかもしれませんが、実は1日に何度もgit cloneを行う環境もあります。そう、CI/CDの環境ですね。

CircleCIやGitHub Actionsといった一般的なCIサービスでは、毎回まっさらな環境にリポジトリをgit cloneするところからスタートします。巨大リポジトリではこの1ステップ目のgit cloneだけで数分かかってしまうため、単にLintを行うだけのジョブであってもすぐに結果が返ってこないという状況になります。

gitでは仮にファイルを削除しても昔のコミットから復元可能です。つまり、過去全ての情報が保存されているためリポジトリは歴史と共に太っていく一方であり、改善の見込みもありません1

ところで、git cloneは知らない人がいないほどポピュラーなコマンドですが、実はオプションが豊富に存在することはご存知でしょうか?手元でman git-cloneを実行してみください。きっと知らないオプションがたくさんあり驚くと思います。オプションの中には不要な過去のコミット、ブランチ、ファイルなどを取得しないことでgit cloneを効率的にできるものがありますので、今回はこれらを活用するテクニックをお伝えします。

git cloneの各種オプション

オプションなし

この後に各種オプションを駆使したgit cloneを紹介するのですが、まずは比較のために何もオプションを付けないときの実行時間と、checkout後のリポジトリのサイズを計測しておきます。実験に使用するリポジトリには、歴史がとても長いgit/gitを採用しました。

$ time git clone git@github.com:git/git.git
Cloning into 'git'...
remote: Enumerating objects: 279, done.
remote: Counting objects: 100% (279/279), done.
remote: Compressing objects: 100% (99/99), done.
remote: Total 298430 (delta 193), reused 248 (delta 180), pack-reused 298151
Receiving objects: 100% (298430/298430), 148.71 MiB | 10.06 MiB/s, done.
Resolving deltas: 100% (222617/222617), done.
Updating files: 100% (3866/3866), done.

real    0m27.057s
user    0m14.959s
sys 0m3.038s

$ du -sh git git/.git
214M    git
169M    git/.git

時間についてはインターネット回線、時間帯などに依存するためあくまで参考程度にしてください。時間に加えてcheckout後のリポジトリそのものと、昔のコミットやgitのオブジェクトなどの情報を全て格納している.gitだけのサイズを計測しています。

これを基準とし、どこまで改善できるのかを見ていきましょう。

depth

まずは知ってる人も多いであろうdepthです。depthを指定すると、その数のコミットの歴史だけに限定したデータを取得します。これをshallow cloneと呼ぶことがあり、gitやCIサービスのドキュメントなどにも頻出しますので、shallow = depthなどのオプションによって歴史を省略したcheckoutと覚えておくとよいです。

過去のコミット分の情報が不要ということは、昔のファイルの状態を記録しているGitオブジェクトが軽くて済むということです。2

ではどれほど効果があるのか試してみましょう。

$ time git clone --depth=1 git@github.com:git/git.git git_depth1
Cloning into 'git_depth1'...
remote: Enumerating objects: 3960, done.
remote: Counting objects: 100% (3960/3960), done.
remote: Compressing objects: 100% (3506/3506), done.
remote: Total 3960 (delta 356), reused 1917 (delta 287), pack-reused 0
Receiving objects: 100% (3960/3960), 9.30 MiB | 7.25 MiB/s, done.
Resolving deltas: 100% (356/356), done.
Updating files: 100% (3866/3866), done.

real    0m11.401s
user    0m0.466s
sys 0m0.862s

$ du -sh git_depth1/ git_depth1/.git
 56M    git_depth1/
 11M    git_depth1/.git

(繰り返しになりますが、git cloneにかかる時間自体は様々な要因によって安定しない可能性が高いので、あくまで参考にしてください)

--depth=1を指定すると、指定したブランチ(指定しない場合はデフォルトブランチ)の最後のコミットだけを取得します。オプションを何も付けなかった場合と比較して、時間は1/2以下、リポジトリの容量は約1/4で.gitだけに限定すれば約1/15程度になりました。時間に関しては誤差があるとはいえかなり改善できました。

代わりに、depth付きでgit cloneしたリポジトリではgit logをしても過去のコミットを見ることはできませんし、昔のコードをcheckoutもできません。とりあえず最新のリポジトリの状態でビルドしたいというCIなどの用途においては過去のコミットの情報は不要なので、メリットだけを享受できるでしょう。

single-branch

git cloneについて調べるとdepthの次に目にすることが多いのはsingle-branchではないでしょうか。その名の通り、単一のブランチだけの情報を取得するので通常よりは取得するデータ量が減ります。しかし、depthとは異なり指定したブランチの歴史は全て取得するので、削減量としてはdepthには及ばないはずです。実際に試してみましょう。

$ time git clone -b master --single-branch git@github.com:git/git.git git_single_branch
Cloning into 'git_single_branch'...
remote: Enumerating objects: 832, done.
remote: Counting objects: 100% (826/826), done.
remote: Compressing objects: 100% (804/804), done.
remote: Total 289165 (delta 28), reused 814 (delta 22), pack-reused 288339
Receiving objects: 100% (289165/289165), 143.57 MiB | 9.93 MiB/s, done.
Resolving deltas: 100% (216511/216511), done.
Updating files: 100% (3866/3866), done.

real    0m25.670s
user    0m13.348s
sys 0m2.899s

$ du -sh git_single_branch/ git_single_branch/.git
201M    git_single_branch/
155M    git_single_branch/.git

期待通りの結果になりました。通常よりは多少軽くなりましたが、depth=1には及びません。single-branchを使う場合の注意点としては、今後fetchやpullをした場合でもsingle-branchで指定したブランチ以外のリモートブランチは取得されないことです。つまり、pull-requestが出ているブランチをcheckoutしてエディタ上でコードレビューする、みたいな使い方はできなくなります。

この挙動になる理由はfetchのrefspecを見てもらうと分かります。各オプションでgit cloneしてきたリポジトリで比較してみます。

$ git config --get remote.origin.fetch

# 通常
+refs/heads/*:refs/remotes/origin/*

# depth=1
+refs/heads/master:refs/remotes/origin/master

# -b master --single-branch
+refs/heads/master:refs/remotes/origin/master

refspecを解説するとそれだけで1つ記事が書けてしまうので今回は省略しますが、depthsingle-branchのどちらもこのrefspecによって取得するブランチをmasterだけに限定していることが高速化3のポイントです。refspecについてもっと深く知りたい方はドキュメントを見てみましょう。

補足:depth=1single-branchの併用について

git cloneの高速化でよく目にする例として、git clone --depth=1 -b master --single-branchのように2つのオプションを併用するものですが、これは高速化の観点では意味はないはずです。なぜならdepthを指定している時点で取得するコミットの歴史は制限されていますし、先ほど示したようにrefspecも特定のブランチに限定されているためです。

shallow-since

これは少しマイナーなオプションですがdepthの日付版だと思ってください。depthではコミットの数で限定しましたが、shallow-sinceはある時点以降のコミットに限定して取得できます。CIの用途ではdepthで十分だと思いますが、例えば歴史が長いリポジトリにおいてある時期に全体的に書き換えてv2にしたのでもう昔のv1時代のコミットはgit logで見ることもない、というケースなどで自分のマシンにcloneする場合は便利かもしれません。

$ time git clone --shallow-since="2020/01/01" git@github.com:git/git.git git_shallow_since1
Cloning into 'git_shallow_since1'...
remote: Enumerating objects: 27410, done.
remote: Counting objects: 100% (27410/27410), done.
remote: Compressing objects: 100% (12816/12816), done.
remote: Total 27410 (delta 19562), reused 20870 (delta 14326), pack-reused 0
Receiving objects: 100% (27410/27410), 26.83 MiB | 9.33 MiB/s, done.
Resolving deltas: 100% (19562/19562), done.
Updating files: 100% (3866/3866), done.

real    0m17.672s
user    0m2.304s
sys 0m1.225s

$ du -sh git_shallow_since1/ git_shallow_since1/.git
 74M    git_shallow_since1/
 28M    git_shallow_since1/.git

本当に2020/01/01以降のコミットだけを取得しているのか調べてみましょう。結果に1つだけ2019-12-31が含まれていますが、おそらくタイムゾーン周りをあまり厳密に考慮しなかったからだと思われます。

$ git log --format="%H %as" | tail
0d2116c6441079a5a1091e4cf152fd9d5fa9811b 2020-01-05
9d48668cd51220f1d8b83c7e1aa65416520aeee6 2020-01-04
3a05aacddd8e3d65ba7988dc6e4f8a88bc4e3320 2020-01-04
4c5081614c0fa5a9060ae294cc4364f990254f04 2020-01-03
5bb457409c15d221ee38240f83362b3c6fd96421 2020-01-03
63020f175fe26f3250ac8d19d02ef9ee271006e5 2020-01-02
224c7d70fa14ed44d8e7e3ce1e165e05b7b23725 2019-12-31
9c8a294a1ae1335511475db9c0eb8841c0ec9738 2020-01-02
763a59e71cee1542665e640f63141a0bf89e6381 2020-01-01
44143583b76decf93c55b73adaf2367c22c88998 2019-12-31

sparse-checkout

ここまでは過去のコミット、つまり時間方向へのデータ量削減でした。別の方向としてcheckoutするファイルを限定するということも可能です。

sparse-checkoutによって、巨大なリポジトリの中から必要なファイルとそれに関係するコミットだけを取得できます。例えば、複数のプロジェクトが1つのリポジトリに同居するmonorepo構成の場合に、自分の作業に関係するプロジェクトだけをcheckoutできれば十分というケースで重宝します。

これを実現するためのgit sparse-checkoutというコマンドがv2.25.0から追加されたようなのですが、v2.29.0時点ではまだEXPERIMENTALとのことなのでここでは昔ながらの方法を使います。

# 自前でディレクトリを作って空のリポジトリを作るところからスタート
$ mkdir git_sparse
$ cd git_sparse
$ git init
$ git remote add origin git@github.com:git/git.git

# git/gitのDocumentationディレクトリのみをcheckoutするように設定
$ git config core.sparsecheckout true
$ echo "Documentation" > .git/info/sparse-checkout

# ここで初めてデータを取得しにいく
$ time git fetch --depth=1 origin master
remote: Enumerating objects: 3960, done.
remote: Counting objects: 100% (3960/3960), done.
remote: Compressing objects: 100% (3506/3506), done.
remote: Total 3960 (delta 356), reused 1918 (delta 287), pack-reused 0
Receiving objects: 100% (3960/3960), 9.30 MiB | 7.35 MiB/s, done.
Resolving deltas: 100% (356/356), done.
From github.com:git/git
 * branch            master     -> FETCH_HEAD
 * [new branch]      master     -> origin/master

real    0m11.582s
user    0m0.259s
sys 0m0.126s

# `git init`で作られたmasterブランチとfetchしたorigin/masterは全く別物なので、masterを上書きして完成
$ git reset --hard origin/master

$ du -sh git_sparse/ git_sparse/.git
 16M    git_sparse/
 10M    git_sparse/.git

git cloneが裏で行っている手順を手動で行っているので手間がかかりますが、こうして出来上がったリポジトリはgit/gitのDocumentationディレクトリだけがcheckoutされた状態なのでとても軽くなりました。

git cloneのオプションまとめ

ここまでの結果をまとめるとこのようになります。

オプション 時間(timeのreal) checkout後のサイズ .gitのサイズ
なし 0m27s 214M 169M
--depth=1 0m11s 56M 11M
-b master --single-branch 0m25s 201M 155M
--shallow-since="2020/01/01" 0m17s 74M 28M
sparse-checkout + depth=1 0m11s 16M 10M

実行時間に関してはあくまで参考値ですが、depthを付けると相当に改善が見込めそうです。checkout後のリポジトリのサイズは、当然ですがsparse-checkoutによってファイルを限定できれば無駄な容量を削減できることが分かるかと思います。

submodule

ここまではgit cloneによる1つのリポジトリのケースを見てきましたが、実際の業務ではsubmoduleをたくさん抱えているリポジトリに出会うこともあるかと思います。git cloneにはcloneと同時にsubmoduleを取得し、さらにそのときの振る舞いを制御するオプションも存在します。

歴史あるOSSをいくつか調べてみたのですがサンプルになるようなsubmoduleをたくさん抱えているリポジトリが見つからなかったため、社内のリポジトリを使って実験しました。そのため、詳細なコマンドは省いて結果だけを示します。

オプション 時間(timeのreal) checkout後のサイズ .gitのサイズ
なし(--recurse-submodules 3m15s 4.2G 2.7G
--depth=1 --recurse-submodules --shallow-submodules 2m2s 2.5G 1.0G
--depth=1 --recurse-submodules --shallow-submodules -j4 1m57s 2.5G 1.0G

--recurse-submodulesgit submodule update --init --recursiveと同じ処理をgit cloneと同時にするオプションです。git cloneだけでsubmoudleのセットアップも完了するので便利です。

--shallow-submodulesを付けるとsubmoduleのリポジトリがdepth=1git cloneされます。submoduleのリポジトリについて過去のコミットが不要であれば速度の向上、少なくともリポジトリのサイズを減らすことができます。

-j4(--jobs=4)はドキュメントによるとsubmoduleのfetchを同時に行う数とのことです。個人的には4並列にすることでもう少し時間が短縮されることを期待していたのですが、ほぼ変化はありませんでした。

今回実験したリポジトリはsubmoduleの数自体は多いのですが、一部のsubmoduleだけが突出してサイズが大きいという構成上、並列化の恩恵を受けにくかったのかもしれません。あるいは社内ネットワークに由来する何かがボトルネックだった可能性もありますが、今回の本題から外れてしまうので深く調査は行いませんでした。

各CIサービスの対応状況

git cloneの各オプションの効果について理解できたと思いますので、実際のCIサービスでこれらが活用できるのかを見ていきましょう。

なお、調査したのは執筆時点の2020/12です。各サービスとも日進月歩で更新されていますし、フォーラム等で機能要望を受け付けているので将来的には改善されているかもしれません。

CircleCI

CircleCIでは何も意識せずに最初のステップでcheckoutしていると思いますが、実はこの裏ではdepthオプション無しでgit cloneが実行されているため過去の全コミットが取得されています。これはCircleCIのログでCheckout codeのステップのログから確認できます。

CircleCIのサポートからは以下のページにて、checkoutの代わりに自前で--depth--shallow-sinceオプション付きでgit cloneするか、そのような機能を提供しているOrbsの利用が提案されています。

Shallow Repository Cloning

他に探したところ、30GBを超える超巨大なリポジトリをdepthsparse checkoutで対応した事例もあるようです。

巨大なリポジトリのJenkinsからCircleCIへの移行においてshallow cloneとsparse checkoutで前処理を高速化する

Bitrise

Bitriseのsteps-git-cloneはデフォルトではdepthのオプションが有効になりません。ですが、clone_depthというオプションを設定するとdepthオプション付きでcloneされるようになります。

https://github.com/bitrise-steplib/steps-git-clone/blob/b1cbe45b5704863c4c930111edeb7aa67d38f5c1/step.yml#L101-L107

sparse checkoutや、submoduleへのdepthの対応は自分がコードを読んだ限りでは無いように見えました。それらの対応が必要な場合は自分でgit cloneのコマンドを書く必要があります。

GitHub Actions

GitHub Actionsのactions/checkoutは、v2からデフォルトでdepth=1のオプションが有効になっています。逆にdepth無しでcloneが必要な場合はdepth: 0を指定するようにとREADMEに書いてあります。

submoduleについてはREADMEに詳細は書かれていませんでしたが、fetch-depthを指定するとsubmoduleを取得するときにもdepthが有効になりそうです。(v2.3.4のコードより)

https://github.com/actions/checkout/blob/v2.3.4/src/git-command-manager.ts#L317

sparse checkoutはサポートされていないようでしたので、必要な場合は自分で一連の処理を書く必要があります。

まとめ

大規模リポジトリでgit cloneするときのニッチなテクニックを紹介しました。

業務のリポジトリは何年も生き続けて肥大化していく一方なので、CIにおける最初のgit cloneにいつの間にか数分かかってしまうということも珍しくありません。そこはCIサービス側がいい感じに対応してくれていると思いきや、意外とそんなことはなかったりします

git cloneのオプションについて理解しておくことで、CIサービス側が用意している機能にオプションを渡すだけで改善の余地があるのかどうかを判断できるようになります。最悪、用意されている機能には頼らずに自分でgit cloneのコマンドを書くことでCIの実行時間を短縮できる可能性があります。

git cloneのオプションは他にもたくさん存在しており、なかなか奥深いのでman git-cloneを実行するか、Webのドキュメントをぜひ一読してみてください。

宣伝

DeNAでは今年、以下の3種のアドベントカレンダーを書いてます!それぞれ違った種類なのでぜひ見てみてください。

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします。

また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!


  1. コミットの歴史を修正してファイル自体がなかったように歴史を改変してしまう場合はその限りではないですが、相当に難しい作業です。

  2. .git/objectsの中身について興味がある方は10.2 Gitの内側 - Gitオブジェクトが参考になるでしょう。

  3. このrefspecについて理解できるとPinterestで1行追加しただけでgit cloneが99%高速化されたという理由が分かります。

Goによるロードテスト

はじめに

SWETグループGoチームの金子 (@theoden9014) です。

弊社が運営するライブコミュニケーションアプリであるPococha(ポコチャ)においてロードテストを実施する際、Go言語を利用して独自のロードテストツール開発しました。今回は、その知見を共有したいと思います。

この記事はDeNA Advent Calendar 2020の4日目の記事です。

本記事ではシステムをWebシステムと前提としていますのでご注意ください。

ロードテストとは (Load Testing)

JSTQBISTQBにおいては性能テスト (Performance Testing) の1つと定義されています。 性能テストとはシステムテストにおける非機能テストの1つです。 ロードテスト、耐久性テスト、ストレステスト等があり、それぞれ目的に応じて実施します。

ロードテストは、システムがユーザ負荷のピーク時でも継続して稼働できるか検証します。 サーバ等のインフラも含めて本番規模のシステム構築するケースが多く、非常にコストが掛かる検証です。 よって、システム導入時や大規模なシステム改修時など、要所要所で単発的に実施する事が多いです。

検証する観点はサービスの性質やシステムの特性によって違うので一般化することは難しいです。例えば、想定されるスパイクアクセスに耐えられるか、想定される同時アクセス数を捌く事ができるかのような観点で検証します。

ロードテストはパフォーマンスの目標値をまず最初に設定します。この目標値を満たすことができるかロードテストツール使って検証し、満たしていない場合はシステム全体でボトルネックとなっている箇所を特定し、そのボトルネックを改善します。

この検証、分析、改善のサイクルを目標値を満たすまで繰り返し行います。

ロードテストにおいてボトルネックの特定は最も重要な要素です。 ロードテストを実施する際にはロードテストツールのメトリクスでは不十分で、システムの可観測性を高めておく必要があります。 しかし、今回は記事の主旨がロードテストツール側にあるので、検証対象のシステムについての可観測性については割愛させていただきます。

ロードテストツールの選定

最初に設定した目標値を元に、具体的な負荷シナリオや変動させるパラメータの設計をします。

静的なリクエストを生成するにはapachebenchvegetaなどのツールがあります。 ロジックに基づいたリクエストパラメータの生成やURLの生成などの、動的なリクエストを生成するにはGatlingJMeterLocust等のツールがあります。

このようなロードテストツールは他にもいくつかあります。 しかし、どうしてもユースケースに合わなかったり、欲しい機能が存在しなかったりといったケースが発生することもあります。 そういった場合は既存ツールを拡張するか、独自でロードテストツールを開発することになります。

今回のロードテストの要件

今回は今後想定されるユーザ数のリクエストを問題なく捌けるか検証したかったので、重要機能においての特定ユースケースのロードテストを実施しました。 同時利用ユーザ数、APIエンドポイント毎の秒間実行回数を目標値として設定し、その他変動させる必要のあるパラメータの設計をしました。

今回は様々な要件がありましたがその中でも難しかったのは、キャッシュのヒット率を下げるためにユーザの行動に重み付けをした上でランダム性を持たせなければならない、というところでした。例えば、ライブ視聴中におけるユーザの行動を挙げると以下のようなパラメータです。

  • コメントやアイテムやいいねの送信、他ユーザへの拍手、等々の行動の選択
  • 送信するコメント内容、アイテムの種類、拍手する対象ユーザの選択

認証やライブ視聴中のKeepAliveなど他にも技術的な要件や課題がいくつかあり、既存のロードテストツールでそれらを実現しようとすると通常の機能だけでは難しいと判断し、今回は使い慣れているGo言語を使ってロードテストツールを独自で実装することにしました。

Goによるロードテストツールの実装

Goではgoroutineとchannelを利用することで並行処理をシンプルに記述できます。 今回は以下のようなアーキテクチャで開発しました。

20200930130505

全てのコードの解説は行えないので、今回はポイントとなるコンポーネントであるEvent (+EventGenerator) とDispatcherを抜粋してご紹介します。 EventGeneratorではリクエストパラメータにランダム性を担保させるようにし、Dispatcherではそれを実行するユーザにランダム性を担保させるようにしています。

まずEventについてです。 Eventは処理を行う単位としており、行いたい処理の種類によって構造体とそれを生成するための関数を定義していきます。

一定の間隔でランダムなパラメータの生成を行い、channelに対してEventを送信します。

import (
    "context"
    "time"
)

type Event interface {
    Emit(ctx context.Context, c *APIClient) error
    Name() string
}

type commentEvent struct {
    // イベント生成側と処理側で共有するパラメータをここに記述します
    text string
}

// イベントの処理内容、複数のAPIで整合性が必要な処理もここにまとめます
// ここでは認証などの必要な初期化処理を行ったAPIClient(ユーザ)を再利用したいので引数で渡せるようにしています
func (e *sendCommentEvent) Emit(ctx context.Context, c *APIClient) error {
    if err := c.LiveViewing.SendComment(ctx, e.text, e.toUserID); err != nil {
        return err
    }
    return nil
}

// トレーシングや統計情報を集計する際に別のイベントと区別する為のイベント名
func (e *sendCommentEvent) Name() string {
    return "request: send comment"
}

// 任意のユーザがコメントを送信するイベントを発生させます
func commentEventGen(ctx context.Context, d time.Duration) chan Event {
    // EventGenはd間隔でイベントを生成して戻り値のチャネルに対して送信します
    return EventGen(ctx, d, func(tm time.Time, ev chan Event) {
    // 今回は適当にイベント生成時の時間を生成しています
    // ランダム性のあるパラメータはここで担保できるようになります
        ev <- &commentEvent{
            text: tm.String(),
        }
    })
}

// EventGenはイベントを一定間隔で生成する為の関数を生成する為のヘルパー関数
// 第三引数の関数でパラメータを与えてEventを生成してチャネルに送信する処理を記述します
func EventGen(ctx context.Context, d time.Duration, f func(time.Time, chan Event)) chan Event {
    eventChan := make(chan Event)
    if d == 0 {
        return eventChan
    }

    go func() {
        ticker := time.NewTicker(d)
        defer ticker.Stop()

        for {
            select {
            case t := <-ticker.C:
                f(t, eventChan)
            case <-ctx.Done():
                close(eventChan)
                return
            }
        }
    }()

    return eventChan
}

次に、生成されたイベント毎のchannelを1つに集約し、各ワーカーに対して処理を委任していく役割であるDispatcherについてです。 このDispatcherは一番重要な箇所です。 様々な種類のEventを1つのchannelにまとめます。その1つのchannelを複数のワーカーが受信することによって偏りを持たせずに処理を行えるようになります。

1ワーカー1ユーザとなるようにしているので、これによって各ユーザが実行するイベントの種類にランダム性を持たせてることが可能になります。 今回は記事の尺の都合上、他コンポーネントについては割愛させていただくのでinterfaceとして定義しております。

import (
    "context"
    "sync"
)

type Worker interface {
    WaitEvent(context.Context) Event
    Process(context.Context, Event) error
    Close() error
}
type WorkerPool interface {
    Get() Worker
    Put(Worker)
}

type Dispatcher struct {
    mu     sync.RWMutex
    inputs []<-chan Event

    pool WorkerPool
}

// 処理するイベントのchannelをセットします
// 複数のchannelをセットすることが可能です
func (d *Dispatcher) In(ev chan Event) {
    d.mu.Lock()
    defer d.mu.Unlock()
    d.inputs = append(d.inputs, ev)
}

type WorkerFunc func(context.Context, Event) error

// これを利用してWorker追加時に、WorkerFuncのクロージャ内にAPIClientを閉じ込めることによって
// 1Worker---1APIClient--1User の紐付けを行うことができます。
func (d *Dispatcher) NewWorker(id string, f WorkerFunc, r <-chan Event) {
    w := &worker{
        id: id,
        f:  f,
        r:  r,
    }
    d.pool.Push(w)
}

// Startはchannelを繋ぎ合わせ、設定したWorkerを起動していくメソッドです
func (d *Dispatcher) Start(ctx context.Context) {
    // d.inputs のチャネルを1つのチャネルに集約していきます
    input := make(chan Event, len(d.inputs))
    for _, evc := range d.inputs {
        evc := evc
        go func(evc <-chan Event) {
            for {
                select {
                case ev := <-evc:
                    input <- ev
                case <-ctx.Done():
                    return
                }
            }
        }(evc)
    }

    // WorkerPoolからWorkerを取得して、
    // それぞれのWorkerをgoroutineでイベントループで起動していきます
    for w := d.pool.Get(); w != nil; w = d.pool.Get() {
        w := w
        go func() {
            defer w.Close()
            for {
                select {
                case ev := <-input:
                    if ev == nil { // closed Event channel
                        return
                    }
                    w.Process(ctx, ev)
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
}

これらのコードを以下のように繋ぎ合わせることで、複数種類のイベントを、パラメータと実行するユーザにランダム性を持たせることができます。

// 第二引数でイベントの生成間隔を指定、
// それ以降の引数はGeneratorの生成アルゴリズムによって任意の値を指定
cChan := commentEventGen(ctx, 1/10*time.Second)
iChan := itemEventGen(ctx, 1/20*time.Second, itemIds...)
.
.
.
dispatcher := NewDispatcher()
dispatcher.In(cChan)
dispatcher.In(iChan)
.
.
.

// テスト用のユーザデータは予め用意しておいて読み込むようにしておきます
for _, user := range users {
    // クライアントの認証や、
    // その他細かいクライアント毎のセットアップ処理
    auth := NewDebugAuthenticator(user)
    // 今回はアプリケーションの負荷を確認したかったのでコネクションプールを共通化していますが、
    // インフラ側のテストも兼ねて共通化したくない場合は、
    // APIクライアント毎にコネクションプールを作った方が良いでしょう。
    client := NewAPIClient(baseURL, http.DefaultClient, auth)
    // 生成されたイベントはchannelを通じて、
    // クロージャの第二引数であるEventに渡ってきます
    dispatcher.NewWorker(user.Name, func(ctx context.Context, ev Event) error {
        return ev.Emit(ctx, client)
    })
}

dispatcher.Start(ctx)

ボトルネックの可視化

runtime/traceパッケージ

Goでは標準パッケージのruntime/traceを利用することでトレーシングを行うことができます。これを使うとランタイムレベルのトレースと、ユーザアノテーションを使ったトレーシングを行うことができます。 今回はユーザアノテーションを使ったトレーシングについてお話ししたいと思います。

ユーザアノテーションをするにはtrace.Tasktrace.Regionの2つを使って計測したい区間を自分で定義していきます。

trace.Taskはgoroutineを跨いで計測できます。 trace.Regionを使うとgoroutine内の細かいトレース情報を取得できます。しかし、計測範囲をgoroutine内に絞る必要があります。

トレーシングの実施方法

runtime/traceは常にトレーシングを行っているわけではなく、ランタイム内のトレーシングフラグが有効な際にトレーシングが行われます。 以下の2つの方法でトレーシングをフラグを有効にして結果を取得できます。

1つはテスト時にトレーシングを行う方法です。go test -trace=${OUTPUT_FILE}フラグを追加することで${OUTPUT_FILE}にトレース結果のファイルを生成できます。

もう1つはプログラム実行中にhttpハンドラー経由でトレーシングを行う方法です。net/http/pprofを空インポートをするとhttp.DefaultMuxにトレーシングとプロファイリングのエンドポイント用のハンドラーが追加されます。 http.ListenAndServe(":8080", nil) 等でHTTPサーバを立ち上げて:8080/debug/pprof/traceへアクセスするとデフォルトで30秒間、その間だけトレーシングを実施して結果を取得できます。

これらのトレース結果ファイルはgo tool trace ${OUTPUT_FILE} コマンドでWebUIから確認できます

ロードテストツールでの利用

今回はruntime/traceを利用してイベントの種類(今回はAPIエンドポイント)毎にRegionを定義しました。 go tool traceを使うとWebUIから以下のようにイベントの種類毎の処理時間分布を確認できるので、処理が遅くなるエンドポイントを特定できました。(以下の画像はサンプル用に用意したものです)

20201202210836

OpenCensusOpenTelemetryのような分散トレーシングを使っても良いとは思いますが、今回はオーバースペックだと判断したので利用はしませんでした。

参考までにですが、以下のようなMakefileを用意しておくと簡単にプロファイルとトレーシング結果を取得できます。

GO          := go
PPROF_URL   := http://localhost:6060/debug/pprof
DEBUG_TYPES := profile.pdf goroutine.pdf heap.pdf block.pdf mutex.pdf trace.out
DEBUGDIR    := ./debug

.PHONY: debug
debug: debug/clean $(DEBUG_FILES)

.PHONY: debug/clean
debug/clean:
    @rm -f $(DEBUGDIR)/*

$(DEBUGDIR)/%.pdf:
    $(GO) tool pprof -pdf -output $@ $(PPROF_URL)/$*?seconds=30

$(DEBUGDIR)/%.out:
    curl -fsL -o $@ $(PPROF_URL)/$*?seconds=30
    $(GO) tool trace $@

まとめ

これらによって、負荷を生成しつつAPIエンドポイント毎の処理時間の分布が簡単に確認できるようになり、ボトルネックとなっているAPIエンドポイントを特定できました。

このツールはiOSアプリのパフォーマンステストでも利用しているので、興味がある方はこちらの記事もご覧ください。

ご紹介したソースコードは実装の概要をご紹介する為に一部簡略化している箇所や書き換えている箇所がございますのでご注意ください。他にも、実際はgolang.org/x/time/rateを利用してトークンバケット方式でバースト制御して想定外のスパイク負荷を発生させないようにしたりといった様々な工夫をしておりますが今回は省略させて頂いております。

このようにGoを利用するとロードテストツールもシンプルに実装可能です。 みなさまがパフォーマンステストを行う際の選択肢の1つとして助けになると幸いです。

私事ではありますが、汎用化を目的としてパフォーマンステストフレームワークを趣味で開発しているので、もし興味がございましたらご覧ください。

https://github.com/theoden9014/evbundler

関連リンク

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします! また DeNA 公式 Twitter アカウント @DeNAxTech では、Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!

【改訂版】継続的にiOSアプリのパフォーマンスを計測する

はじめに

SWETグループiOSチームのkariad(@kariad_uu)です。

本記事はiOSDC 2020 Japanにて発表した「継続的にアプリのパフォーマンスを計測する」の内容を元にブログという形で改めて紹介する記事となります。 発表時のスライドは以下を参照ください。

iOSチームではiOSアプリのパフォーマンス計測に取り組んできました。 iOSアプリのパフォーマンス計測方法はたくさんありますが、中でもInstrumentsを利用したパフォーマンス計測とその自動化について紹介します。

なぜパフォーマンス計測が必要なのか?

取り組んだ計測方法についてご紹介する前にアプリにとってパフォーマンスとその計測がなぜ重要なのかという点を説明します。

起動に時間がかかる、読み込みに時間がかかるアプリは動作が遅くユーザにストレスを与えてしまいます。 短時間で熱くなってしまうアプリでは端末が熱くて持てなくなってしまうだけではなくバッテリーの消費量も大きくなってしまいます。 また場合によってはパフォーマンスが悪いことでアプリのクラッシュが引き起こされる可能性もあります。 こうした問題はアプリのユーザーの離脱を引き起こしてしまう可能性があります。

アプリのパフォーマンスについてAppleが以下のドキュメントを出しています。 https://developer.apple.com/documentation/xcode/improving_your_app_s_performance

このドキュメントによるとパフォーマンス改善のサイクルを回していくことが推奨されています。 改善のサイクルを回すことでパフォーマンスが、ユーザーが離脱してしまうほど悪化する前に発見、修正できます。

Instrumentsでパフォーマンスを計測する

InstrumentsはXCUITestに組み込んで自動的に計測するような自動化の仕組みはありません。 そのため自動化し、継続的にパフォーマンス計測をするためにはツールを組み合わせる必要があります。

Instrumentsを利用して今回の事例で計測する項目は、CPU使用率とThermal State(端末温度)です。1 以前アプリの性能が悪化した際に原因を調査したところ、CPU使用率が高くなることで端末温度が上昇し、クラッシュに繋がることが判明したからです。

そして今回SWETでは次のような構成で自動計測基盤を構築しました。

  • 最新のアプリ: Bitriseでビルド&resign
  • 実機: HeadSpin
  • 計測環境: Jenkins
  • アプリ操作用UIテストフレームワーク: XCUITest
  • 計測ツール: Instruments CLI
  • アプリに負荷をかけるツール: お手製
  • 計測結果確認手段: Slack

この構成での実行フローは次のようになります。

  1. Bitriseにてreleaseビルドでipaを作成
  2. ipaに対してdevelopment証明書でresign
  3. HeadSpinにipaを配布、dSYMをArtifactsとして保存
  4. BitriseからJenkins jobトリガーを実行
  5. Jenkinsで負荷生成ツールのDL、起動、HeadSpinへの接続等の準備
  6. 計測開始と完了
  7. 結果のtraceファイルをXCUITestを用いてSSの撮影とSlackへの投稿
  8. traceファイルなどをJenkinsのArtifactsとして保存

20200930210832

構成について

なぜこのような構成となったのか、注意するべきポイントとともに、実行フローに沿って説明していきます。

最新のアプリ

コードは毎日変更されます。パフォーマンスが悪化したことをいち早く知るため変更に追従した最新のアプリで計測する必要があります。場合によってはちょっと改善を試したブランチなどもありえます。 またInstrumentsでのデバッグ用にdSYMも必要となるためこれらを毎日ビルドをして用意する必要があります。 しかしただビルドをするだけではなく、Instrumentsで正しく計測できるためにはいくつかの条件を満たす必要もあります。

  1. コンパイラの最適化をreleaseビルドと同等にする。
  2. developmentのCertificateでsignする。

Xcodeのデフォルトの設定ではdebugビルドとreleaseビルドではコンパイラの最適化の設定が異なっています。 releaseビルドでは、開発中頻繁にビルドされるdebugビルドよりもアグレッシブな最適化が行われます。最適化の差異はパフォーマンス計測結果の差異に繋がるため2、実際にユーザーが利用するreleaseビルドと同等の最適化設定でビルドされたアプリを対象に計測する必要があります。

またInstrumentsで計測する際にはdevelopmentのCertificateでsignされている必要があり、distributionではエラーとなってしまいます。

これらをまとめると「releaseと同等の最適化設定を利用しながらdevelopmentのCertificateでsign」という条件になります。 これは一見矛盾しているようにも見えます。計測用のビルドのために設定を変える方法もありますが、今回は別の方法を採用することとしました。それがipaのresignです。

ipaのresignとは生成されたipaに対して、名前の通りsignし直すことができます。 今回の例だとreleaseビルドに対してdevelopmentのCertificateでresignします。 resignにはいくつか方法がありますが、今回はfastlaneのresignを利用することにしました。 fastlaneは次のように書くことで簡単にresignが実行できます。

    resign(
      ipa: ipa_path,
      signing_identity: "iPhone Developer: Xxxxxx Xxxxxxx(FFFFFFFFF)",
      provisioning_profile: {
        "com.swet.app" => provisioning_profile_path,
        "com.swet.app.notificationservice" => provisioning_profile_notification_path
      },
      display_name: "SWET Debug"
    )

これらのビルドは元々Bitriseを利用していたため、そのままBitriseで実施しています。 そして、Bitriseから作成したipaを計測端末に配布します。

実機

Thermal Stateを計測するためには実機での計測が必要となります。 しかし実機を自前で管理すると次のような理由からコストは高くなってしまいます。

  1. テストが走っているタイミングで他のテストが実行されないように排他制御の仕組みを持つ必要
  2. OSのバージョン管理が必要
  3. 物理的な設置が必要
  4. 現在のリモート環境において問題が発生した場合に出社が必要となる可能性がある

これらの問題点を解消するためにクラウド型のデバイスファームを利用するという選択肢があります。 クラウド型のデバイスファームはデータセンターなどに設置された実機を利用できるサービスです。その中の一部サービスではWebなどから実際の手元の実機と同じように操作できる機能が提供されています。クラウド型のデバイスファームの有名所ではAWS Device FarmやRemote Testkitなどがありますが、今回はHeadSpinというデバイスファームを利用することにしました。

HeadSpinを選択した理由はすでにSWETで契約済みであり、追加で費用がかからなかったという点が大きいです。 それ以外にもHeadSpinには以下のような便利な機能があるため利用しています。

  • Webから実際の画面を見ながら操作できる
  • Xcodeから直接Runできる
  • Wi-Fiだけではなく特定のSIMで4G回線での利用もできる
  • AppiumサーバーがHeadSpin側で用意されている
  • 端末単位の契約で他社と共有することがない

20200930154745

HeadSpinはAPIも充実しています。例えば次のようなものがあります。

  • ipaのインストールとアンインストール
  • デバイスのロック、アンロック
  • OSアップデートのポップアップを消す

Bitriseからのipa配布にもこのAPIを利用しています。 その他APIも今回の構成でいくつか利用しています。

計測環境

計測する環境については最大2時間を想定としていたことからBitriseは選択肢に入りませんでした。 (本ブログ執筆時点でBitriseの最大時間は90分) またCircleCIも弊社では契約していますが、Enterprise版を利用しており、macOSでの利用は弊社では利用できません。そうした状況からオンプレで汎用性のあるJenkinsが候補となりました。

Jenkinsを選択したことでJenkins自体の管理が必要になる、という課題が発生します。 しかしSWETにはCI/CDチームが管理しているJenkinsがあります。そのJenkinsに相乗りする形で新たに管理しなくてはいけない項目を増やさずに済みました。このJenkinsはXcodeの新しいバージョンが簡単にインストールできる仕組みなどそれ以外にも便利なしくみが揃っています。またJenkinsで躓いた場合相談できるメンバーが周りにいるという点もJenkinsを採用する上での心強いポイントでした。

そのCI/CDチームがCEDEC 2020で発表した内容がこちらです。 Jenkins周りが気になる方はぜひ御覧ください。

モバイルゲーム開発におけるJenkinsクラウド時代のJenkins構築と管理テクニック

アプリ操作用UIテストフレームワーク

計測のためには、UIテストフレームワークを介してアプリを計測対象画面まで遷移させます。 採用したUIテストフレームワークはXCUITestです。

しかし最初からXCUITestを利用していたわけではありませんでした。 最初はAppium x RSpec(Ruby)を利用していました。

ここでAppiumについて軽く説明します。 AppiumはオープンソースのUIテストフレームワークで様々な言語を利用して実装できます。 その特徴としてWebDriverを用いてアプリを操作します。

20200930210826

上図のとおりAppiumの実行にはAppiumサーバーが必要ですが、相性のよいことにHeadSpin自体がAppiumサーバーの実行環境を用意しています。そこで当初は、AppiumのほうがXCUITestより良い選択肢だと考えていました。

Appiumで起きた問題

Appiumを利用する上でいくつかの問題が発生しました。

  • HeadSpin上のAppiumサーバーを利用する場合Xcodeのバージョンを柔軟にコントロールできない
  • HeadSpin上のAppiumサーバーを利用する場合traceファイルの転送が必要となり転送先を用意する必要がある
  • 原因不明だがconnection errorが発生する

Instrumentsの実行はAppiumサーバーと同じ環境で実行されます。そのため今回の場合はHeadSpin側でInstrumentsも実行されることとなり、向こう側のXcodeのバージョンに依存することとなります。 またInstrumentsの計測結果であるtraceファイルの転送も必要となり、転送先を考える必要がありました。 これらを考慮したときにJenkins上でAppiumサーバーを持つことで、Xcodeのバージョンは簡単に切り替えることができ、traceファイルもJenkinsのArtifactsとして保存すれば良いだけとなることからJenkins側でAppiumサーバーを持つ形へと変更することとなりました。

最後の問題として、負荷をかけ続けた状態で一定時間経つとAppiumで新しいコマンドを実行した際にconnection errorが発生しました。 原因を調査しましたが、詳細はわかりませんでした。しかし前述の理由でAppiumサーバーをJenkinsで立てることになり、Appiumを使い続ける理由がほぼなくなっていたため、XCUITestの挙動を確認することにしました。 すると安定して動作したためXCUITestへ移行したというのが現在です。

アプリに負荷をかけるツール

アプリに負荷をかけ続けた状態でのパフォーマンスを計測することにしました。 そのため、SWETの他チーム(Goチーム)のメンバーと協力して負荷生成ツールを作成しました。 このツールをJenkins上で同時に実行しています。

計測ツール

計測ツールについては以下の理由からInstrumentsを利用することにしました。

  • すでにパフォーマンスが問題となっており、ユーザーへ届ける前に問題がないかを知る必要があった
  • 問題がある場合は素早くその原因を特定し解決したい
  • 一定時間の負荷をかけた状態での端末の温度とCPU使用率の変化を計測したい

これらを満たせるのが現時点ではInsturmentsしかありませんでした。

InstrumentsはXcodeの一部として提供されているツールです。 アプリのパフォーマンスを計測するだけではなく、どこの処理のパフォーマンスが悪いのかまで特定できます。 今回はその特定まで行いたいという動機がInstrumentsを採用した大きな理由の1つです

Instruments実行方法

InstrumentsはInstruments.appとInstruments CLIという2種類に分けることができます。 結果の確認はInstruments.appでしかできませんが、計測だけならばどちらでも可能です。 そのため、コマンド1つで実行できるInstruments CLIを利用しました。 Instruments CLIはAppiumからも利用できます。

計測結果確認手段

Instrumentsではtraceファイルから計測結果を定量的な数値で取得する方法がありません。 (実際にはある程度取れる外部ツールが存在したのですが、この時点では存在を知りませんでした) そのため、結果の確認にはtraceファイルをInstruments appで開き、GUIで確認する必要がありました。 しかし負荷をかけ続けての長時間の計測ともなるとファイルサイズが150〜200MBと大きく、Instruments appで開くまでに1分以上かかることもあります。これを毎回手動で結果確認をするのは面倒です。

その解決策としてInstruments appをXCUITestで操作してスクリーンショットを撮影、Slackに投稿させるという方法を取りました。 Thermal Stateは次のような画面で結果を確認できます。

20200930154923

state 説明
Nominal 平常時、問題がない状態
Fair 対応が必要なほどではないが熱くなり始めている
Serious 非常に熱く、アプリ自体にも影響が出始める
Critical 今すぐに冷やす必要がある

またこの各stateの開始時間と終了時間がThermal Stateのグラフを選択することで詳細な情報としてみることができます。

続いてCPU使用率についても実際のスクリーンショットをもとに説明していきます。

20200930155118

CPU使用率に関してはGUIでもとても数値が見づらいです。 CPU使用率の絶対値はグラフでしか確認できません。 しかもそのグラフでさえもグラフの天井へ張り付いたときの数値が固定ではなくその計測回の一番高い数値になります。そのため複数の計測結果のグラフを並べてもグラフだけではどちらのCPU使用率が高いかわかりません。グラフにマウスカーソルを合わせることで初めて具体的な数値がわかります。 またグラフを選択した際に表示される詳細情報では選択した時間での全体のCPU使用率を100%とした場合の具体的な処理毎の使用率内訳が表示されます。つまり全体の使用率の平均などを見ることはGUIでもできません。

これらのスクリーンショットを撮影してSlackに投稿していますが、Thermal Stateについては詳細から各stateの文字列も取得できるため、そこからCriticalに到達したら失敗させるといったことも可能になりました。 一方でCPU使用率についてはスクリーンショットを撮影していますが、それだけではわからずtraceファイルをInstruments appで見なくてはわからないままです。

20200930155209

パフォーマンス計測で注意するべきこと

これらを組み上げる間には手元で確かめたりと様々な試行錯誤を重ねました。その中でパフォーマンス計測していく上で注意する必要があると気がついた点があります。

端末の状態

計測は1パターンだけでなくいくつかのパターンで計測したい場合があります。 しかし連続して同じ端末で計測すると、前の計測で既にThermal Stateが上がりきった状態で計測を開始してしまうため正しい計測結果が得られません。 環境にもよりますが、端末が冷えて前回の影響を受けずに計測するためには30分~1時間の間隔を開ける必要があります。 この問題には私達も直面し、そこで取った対応として端末数を増やして、さらに次のような実行計画を組むことでなるべく時間のロスなく計測パターンに対応させました。

20201021194915

Thermal Stateなど、物理的に影響のある項目を計測する場合には前回の計測から間を空けて、影響を受けないようにすることが必要になります。

外部気温

空調などで完全に管理された空間で無い限り物理的な端末での端末温度の計測は外部気温の影響を受けます。 それにより冬では大丈夫でも夏にはとても熱くなってしまうということもありえます。 性能が悪化していなくてもユーザーからすると使いづらい状況になるため、パフォーマンスの改善したほうが良いでしょう。 また上記で連続して計測する際に端末が熱い状態で開始すると正しく計測できず、クールタイムが必要になると説明しました。このクールタイムも外部気温が高いほど端末は冷えにくくなるため同じ端末で連続して計測する際には注意が必要となります。

これからについて

まだまだ改善していきたいところやまだやれていないことがたくさんあります。 今後やっていきたいことには次のようなものがあります。

  • xctraceによる計測結果の確認
  • 計測項目の追加と結果確認方法の改善

xctrace

Xcode12からinstrumetsコマンド(Instruments CLI)がdeprecatedとなります。 代わりにxctraceというツールが追加されました。 このxctraceは基本的にはinstrumentsコマンドを置き換えたものになるのですが、新しい機能も追加されています。 その中で最も特徴的なものがXML形式でのデータのexportです。 これによりtraceファイルをInstruments appで開かずとも計測結果のデータを取得できるようになります。 実際にThermal Stateは詳細に表示されていた各stateの開始時間と終了時間がexportできるようになりました。 一方でCPU使用率についてはまだxctraceで取得できません。 これはそもそもグラフでしか表現されていないのが問題かもしれませんが、いつか対応されることを期待しています。

xctraceの使い方

ここでxctraceのexport機能について紹介したいと思います。 以前Xcode12についての記事を書いたのですが、その時点ではまだbeta版であり、なおかつxctraceに関するドキュメントがmanコマンド以外ほぼ存在しないという状態でした。 そのため詳細の紹介を控えたのですがめでたくXcode12がリリースされたため、私が試したexport機能について、私が理解した範囲で紹介します。 また予め断りを入れておきますが、manコマンド以外のドキュメントが無く手探りで試して得られた利用方法のため、正しくない可能性があります。ご了承ください。

xctraceにはrecord、import、export、list、helpの5つコマンドが存在します。 その中でもexportがxctraceから使えるようになったtraceファイルのデータをXMLで出力できるコマンドです。 exportコマンドは次のように使います。例としてTime ProfilerのTemplateで計測した結果を利用します。

いきなり詳細を出力する前に--tocでTOC(Table of contents、計測項目の一覧と思われるが明確な記載は無いので推測です)を出力します。

$ xcrun xctrace export --input xx.trace --toc 

すると次のようなXMLで表現された結果が得られます。

<?xml version="1.0"?>

<trace-toc>
    <run number="1">
        <info>
            <target>
                <device name="device name" uuid="udid"/>
            </target>
            <summary/>
        </info>
        <data>
            <table schema="tick"/>
            <table schema="device-thermal-state-intervals"/>
            <table target-pid="ALL" kdebug-match-rule="0" exclude-os-logs="0" schema="region-of-interest" signpost-code-map="&quot;&lt;null&gt;&quot;"/>
            <table schema="os-log" category="PointsOfInterest"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x1,0x25&quot;"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x2b,0xd8&quot;"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x2b,0xdc&quot;"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x1f,0x7&quot;"/>
            <table codes="&quot;0x2d,*&quot;" schema="kdebug" target="ALL" callstack="user"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x2b,0x87&quot;"/>
            <table codes="&quot;0x31,0xca&quot;" schema="kdebug" target="ALL"/>
            <table category="InduceCondition" schema="os-signpost" subsystem="&quot;com.apple.ConditionInducer.LowSeverity&quot;"/>
            <table target-pid="ALL" kdebug-match-rule="0" exclude-os-logs="0" schema="roi-metadata" signpost-code-map="&quot;&lt;null&gt;&quot;"/>
            <table schema="life-cycle-period" target-pid="ALL"/>
            <table codes="&quot;0x2b,0x65&quot;" schema="kdebug"/>
            <table codes="&quot;0x1,0xa&quot;" schema="kdebug" callstack="user"/>
            <table schema="os-signpost" category="PointsOfInterest"/>
            <table schema="tick" frequency="1"/>
            <table codes="&quot;46,2&quot;" schema="kdebug" callstack="user"/>
            <table sample-rate-micro-seconds="1000" all-thread-states="NO" schema="time-sample" target="ALL" callstack="user"/>
            <table codes="&quot;0x21,0xa&quot;" schema="kdebug" callstack="user"/>
            <table schema="gcd-perf-event" target-pid="ALL"/>
            <table schema="os-signpost-arg" category="PointsOfInterest"/>
            <table target-pid="ALL" exclude-os-logs="0" schema="global-poi-layout" signpost-code-map="&quot;&lt;null&gt;&quot;" colorize-by-arg4="0"/>
            <table target-pid="ALL" kdebug-match-rule="0" schema="global-roi-layout" signpost-code-map="&quot;&lt;null&gt;&quot;" colorize-by-arg4="0"/>
            <table schema="os-log-arg" category="PointsOfInterest"/>
            <table target-pid="ALL" high-frequency-sampling="0" schema="time-profile" needs-kernel-callstack="0" record-waiting-threads="0"/>
            <table target-pid="ALL" schema="kdebug-signpost" signpost-code-map="&quot;&lt;null&gt;&quot;"/>
            <table schema="thread-name"/>
        </data>
    </run>
</trace-toc>

詳細を見るためにはこのTOC XMLの階層を--xpathオプションでたどる必要があります。 例えばこのTOCからThermal Stateの詳細を得るためのコマンドが次になります。

$ xcrun xctrace export --input xx.trace --xpath '/trace-toc/run[@number="1"]/data/table[@schema="device-thermal-state-intervals"]'

このコマンドを実行すると以下のような結果が得られます。

<?xml version="1.0"?>
<trace-query-result>
  <node xpath='//trace-toc[1]/run[1]/data[1]/table[2]'>
    <schema name="device-thermal-state-intervals">
      <col>
        <mnemonic>start</mnemonic>
        <name>Start</name>
        <engineering-type>start-time</engineering-type>
      </col>
      <col>
        <mnemonic>duration</mnemonic>
        <name>Duration</name>
        <engineering-type>duration</engineering-type>
      </col>
      <col>
        <mnemonic>end</mnemonic>
        <name>End</name>
        <engineering-type>start-time</engineering-type>
      </col>
      <col>
        <mnemonic>thermal-state</mnemonic>
        <name>Thermal State</name>
        <engineering-type>thermal-state</engineering-type>
      </col>
      <col>
        <mnemonic>track-label</mnemonic>
        <name>Track</name>
        <engineering-type>string</engineering-type>
      </col>
      <col>
        <mnemonic>is-induced</mnemonic>
        <name>Is Induced</name>
        <engineering-type>boolean</engineering-type>
      </col>
      <col>
        <mnemonic>narrative</mnemonic>
        <name>Narrative</name>
        <engineering-type>narrative</engineering-type>
      </col>
    </schema>
    <row>
      <start-time id="1" fmt="16:03.938.891">963938891916</start-time>
      <duration id="2" fmt="12.28 min">737085646124</duration>
      <start-time id="3" fmt="28:21.024.538">1701024538040</start-time>
      <thermal-state id="4" fmt="Serious">Serious</thermal-state>
      <string id="5" fmt="Current">Current</string>
      <boolean id="6" fmt="No">0</boolean>
      <narrative id="7" fmt="Serious thermal state">
        <thermal-state ref="4"/>
        <narrative-text id="8" fmt=" thermal state"> thermal state</narrative-text>
      </narrative>
    </row>
    <row>
      <start-time id="9" fmt="09:48.940.922">588940922875</start-time>
      <duration id="10" fmt="6.25 min">374997969041</duration>
      <start-time ref="1"/>
      <thermal-state id="11" fmt="Fair">Fair</thermal-state>
      <string ref="5"/>
      <boolean ref="6"/>
      <narrative id="12" fmt="Fair thermal state">
        <thermal-state ref="11"/>
        <narrative-text ref="8"/>
      </narrative>
    </row>
    <row>
      <start-time id="13" fmt="00:00.000.000">0</start-time>
      <duration id="14" fmt="9.82 min">588940922875</duration>
      <start-time ref="9"/>
      <thermal-state id="15" fmt="Nominal">Nominal</thermal-state>
      <string ref="5"/>
      <boolean ref="6"/>
      <narrative id="16" fmt="Nominal thermal state">
        <thermal-state ref="15"/>
        <narrative-text ref="8"/>
      </narrative>
    </row>
  </node>
</trace-query-result>

中身を見てみると、Thermal stateの変化について出力されています。 開始から9.82minがNominal、その直後から6.25minがFair、最後にSeriousが12.28min、あることがわかります。 これはInstruments.appのGUIでThermal stateを選択した際の詳細情報として表示されていたものと同等のものが出力されているように見えます。

例としてThermal stateで試しましたがschemaにある項目であればすべて表示可能です。しかしTime Profilerなどはとてつもない長さのXMLが出力されるなど、正直あまり使い勝手がいいとは言えない気はします。 まだ最初のリリースなのでこれからに期待したいところです。

他項目の計測と結果確認方法の改善

現在他項目の計測も進めています。 項目によってはInstrumentsではなく別の方法で計測するのですが、結果をBigQueryに格納し、DataStudioで閲覧できるように準備中です。 今回の計測についてもこのフォーマットに合わせて閲覧できると計測結果がまとめて確認できるようになるためデータの可視化というのを進めていきたいと考えています。 その他MetricKitなど実際のユーザ環境での計測も検討しています。

まとめ

SWETが取り組んだパフォーマンス計測についてiOSDC Japan 2020で発表した内容をもとに計測の一例を紹介しました。

すべてのアプリがThermal stateを見る必要があるかというと必ずしもそうではないかもしれません。 大切なのは自分のアプリがどんな課題を持ちうるか検討した上で早めにパフォーマンス計測の仕組みを導入しておくことです。

今回紹介した計測でもさらなる改善や異なる観点での計測も考えています。 これからもパフォーマンス計測についてSWETでは取り組んでいきますのでさらなる知見が溜まったらまたブログ等で発信していきたいと思います。

最後に今年のiOSDCではViewの表示、バッテリーなど他にもパフォーマンスに関する発表がありました。 他社がどのようにパフォーマンス計測に対して取り組んでいるかを知ることができ、とても有意義でした。 発表については聞いてくださった皆様ありがとうございます。 当日質問できなかった、このブログを読んで気になることができた等あれば片山までTwitterでぜひお気軽に聞いてください。


  1. InstrumentsはCPU使用率とThermal State以外にも様々な項目を計測可能です。

  2. パフォーマンスへの影響についてはWWDC2019「Getting Started with Instruments」のセッションでも述べられています。

形式手法でデータ構造を記述・検査してみよう:Alloy編

SWETの仕様分析サポートチーム所属のtakasek(@takasek)です。

仕様分析サポートチームでは、社内のプロダクト開発に対する形式手法の活用可能性を模索しています。当ブログでも、継続的に形式手法に関する情報発信をしています(形式手法 カテゴリーの記事一覧)。

当記事は、Kuniwak(@orga_chem)により社内開催されたAlloyガイダンスを元に再構成した記事です。よく知られたデータ構造であるStackを形式仕様記述しビジュアライズすることで、Alloyの使い方と利点を実感できます。Alloy未経験者でもステップバイステップで試せるように構成しました。是非、お手元にAlloyをインストールして読み進めてください。環境はAlloy 5.1.0を想定しています。

https://github.com/AlloyTools/org.alloytools.alloy/releases

Alloyとは

形式手法の言語およびツールです。システムの構造の制約や振る舞いを簡易に、しかし強力に記述できるようデザインされています。記述されたモデルは要素同士の関係グラフとして視覚化できるというのが特徴です。

もっとも単純なモデルを記述する

Alloyのエディタを起動したら、以下を記述してください。

sig A {}

f:id:swet-blog:20200501184146p:plain

sig はsignatureを意味し、atom(要素)の集合を示します。signatureはオブジェクト指向プログラミングのclassと書き方がなんとなく似ていますが、その実態はまったく異なるものなのでご注意ください。

Executeの結果

ExecuteボタンをクリックするとVisualizerが開きます。Visualizerでは、Nextボタンをクリックするたびにインスタンスが次々とグラフとして表示されます。インスタンスというのは、関係における変数を特定の値で束縛したものです。1

f:id:swet-blog:20200501184029g:plain

Aのatomが0, 1, 2, 3, 4個存在する例を確認できました。現実的には当然5個以上になることもありますが、個数が増えると探索コストが急激に増大するためAlloyは割り切ってデフォルトの探索範囲を少なくしています。それでもおおよその反例は見つかるだろうという前提を小スコープ仮説といいます2。もし無限のケースについて正しさを証明したい場合は、CoqやIsabelleに代表される定理証明支援系の技術が必要になります。

2シグネチャの関係を記述する

sigを2つに増やして、その関係を記述してみましょう。

sig A {
    b: B // 集合Aに属する各々のatomは、集合Bに属するただ1個のatomと関係を持つ
}

sig B {}

Executeの結果

抜粋すると…

関係 グラフ
Aの要素:0個
Bの要素:1個
Aの要素:1個
Bの要素:2個
Aの要素:2個
Bの要素:2個
Aの要素:2個
Bの要素:2個

最後の例があることは提示されて初めて気づく人も多いのではないでしょうか。このように、パターンを網羅的に目で見て確認できるのがAlloyの良いところです。

関係の多重度は、さまざまな形で表現可能です。

sig A {
    b: B, // 1個の要素への関係
    c: one C, // 1個の要素への関係(※省略はoneを意味する)
    d: lone D, // 0〜1個の要素への関係
    e: set E // 0個以上の要素への関係
    // 他にもあります
}

Stackを表現する

ここからが本題です。Stackをモデルとして記述していきましょう。見慣れた連結リストです。

sig Stack {
    value: univ,
    next: lone Stack
}

Stack というsignature、すなわち集合を定義しました。

Stackvaluenext という2つのフィールドを持つsignatureです。フィールド value は、Stackに属する各々のatomと、univに属するただ1個のatomとの関係を示します。フィールド next は、Stackに属する各々のatomと、Stackに属する0〜1個のatomとの関係を示します。なお、univは「あらゆるatomが属する集合」を意味しています。

(勘の良い方は、このモデリングに「おや?」と思われたかもしれません。後ほどその点にも触れますので、今はこのまま読み進めてください)

ここまで書けたら、Executeします。

ビジュアライズの結果を読み解く

結果を見る前に…

f:id:swet-blog:20200501184118g:plain

数値が変わるだけの同じような例が連続して出てきてキツいですね。よく見るとVisualizerのステータスバーに Run Default for 4 but 4 int, 4 seq expect 1 とあります。実はAlloyは、Executeの対象がない場合は暗黙的に以下のような出力命令が省略されているものと解釈しているようです(Alloy5現在)。

run{} for 4 but 4 int, 4 seq expect 1

for 以下は、Visualizerの表示要素数の範囲(スコープ)を示します。なお省略されたrunに任せず明示的に run {} と書いた場合には、 run{} for 4 but 4 int, 4 seq expect 1 よりも少量のインスタンスがビジュアライズされるようです。なぜそうなるのかは調査できていません。

ビジュアライズ結果を読み解く

run {} で再度Executeして、ビジュアライズされた関係グラフを読み解きましょう。最初のグラフはこれですが…

いきなり想定外のグラフです。Visualizer上の Stack のnextが自分自身になっています。データ構造としてStackは循環を許しません。

「Stackは循環しない」という不変条件を書く

Stackが循環しないことを示すためには「このモデルは常にこうなる」という制約を与える論理式(不変条件)を書く必要があります。sig 内の記述だけに着目すれば成立するインスタンスでも、与えられた制約に違反する場合はビジュアライズされません。

「nextは自分自身にはならない」ことを示す

ただし不変条件を正しく書けなかった場合、正常なはずのインスタンスも表示されなくなってしまい、かつそれに気づけないリスクがあります。人間は異常なものを見つけることは得意ですが、あるべき正常なものの欠損に気づくのは苦手です。まずは論理式が本当に過不足のない制約を表現しているかを確認すべきです。

そのために表明(assert)を使います。不変条件はモデルに対して強制的に適用される論理式ですが、表明は現状のモデル自体には影響を与えず「モデルの制約はそうなっているはず」を表現する論理式です。3

今回、Stackが循環しないことを示すには「nextは自分自身にはならない」論理式を書きます。

// 「すべてのStackについて、自身のnextの集合に、自身が含まれない」という表明
assert TestRecursion {
    all s: Stack | s not in s.next
}
// check命令は、表明に違反するインスタンスがないかを確認します
check TestRecursion

ツールバーから Execute > Check TestRecursion すると、コンソールに以下のようなメッセージが表示されました。

Executing "Check TestRecursion"
   Solver=sat4j Bitwidth=4 MaxSeq=4 SkolemDepth=1 Symmetry=20
   483 vars. 72 primary vars. 827 clauses. 7ms.
   Counterexample found. Assertion is invalid. 3ms.

反例(counterexample)、すなわち現状のモデルの中で表明に違反するインスタンスが見つかりました。グラフとして確認できるので見てみましょう。

いいですね。「すべてのStackについて、自身のnextの集合に、自身が含まれない」の反例……すなわち「自分自身がnextになる」インスタンスだけが出力されています。表明をそのまま不変条件に昇格させれば「常にモデルがそれを満たす」という制約の記述になります。

記述全体は以下のようになります。不変条件の記述は fact です。

sig Stack {
    value: univ,
    next: lone Stack
}
fact NoRecursion {
    all s: Stack | s not in s.next
}

不変条件を加えたモデルが想定どおりか、runして確認していきましょう。

run {}

すると、気になるものが見つかりました。

あるStack自身の循環はなくなりましたが、 Stack0.nextStack1Stack1.nextStack0 という間接的な循環が残っています。

「nextを辿った先が自分自身にはならない」ことを示す

「nextは自分自身にはならない(s -(next)-> s)」ことは示しましたが、それだけでは足りなかったのです。 s -(next)-> ... -(next)-> s という関係を表現する必要があります。

そのためには推移閉包を使います。厳密な定義は置いといて、ざっくり言えば「関係Rについて s1 -(R)-> s2 s2 -(R)-> s3 が言えるとき s1 -(R)-> s3 も言える」という関係の集合が推移閉包です。Googleイメージ検索したら感覚は掴めると思います。Alloyには推移閉包オペレータがあり、 ^ をつけるだけで推移的関係を表現可能です。

……というのは本当でしょうか? 我々には確認の手段があります。Alloyの力を借りましょう。 fact NoRecursion はそのまま、表明を追加します。最初に書いたTestRecursionからの差分は、nextに推移閉包オペレータをつけただけです。

...
fact NoRecursion {
    all s: Stack | s not in s.next
}
assert TestRecursion {
    all s: Stack | s not in s.(^next)
}
// 似たようなIntの例が多すぎて邪魔なので、forでスコープを絞ってcheckします
check TestRecursion for 2 Int

再びツールバーから Execute > Check TestRecursion します。さっき見つけた双方向の矢印のパターンが反例として上がってくればしめしめです。どうでしょうか……

nextが双方向の矢印になる例だけが見つかりました。Stackの表示要素数が2個より多い場合の関係も検査してみましょう。

check TestRecursion for 2 Int, 4 Stack

3個のStackが循環参照している反例が見つかりました。いい感じです。

では、表明を不変条件に格上げします。

fact NoRecursion {
-    all s: Stack | s not in s.next
+    all s: Stack | s not in s.(^next)
}

含意の確認をする

ちょっと待ってください。本当にこの格上げは正しいのでしょうか。推移閉包によって「nextを辿った先が自分自身にはならない」ことを表現できました。しかし、これを満たすとき、今までの「nextは自分自身にはならない」も満たすかと言われると確証がありません。感覚的には間違っていないように思えますが、論理の力で保証したいところです。

つまり、論理式「『nextを辿った先が自分自身にはならない』ならば『nextは自分自身にはならない』」が正しいことを確認したいのです。

assert TestImplies {
    (all s: Stack | s not in s.(^next))
        implies (all s: Stack | s not in s.next)
}

implies は含意、つまり論理学の初歩で習う「AならばB」です。

check TestImplies をExecuteすると、コンソールに以下のメッセージが表示されました。

Executing "Check TestImplies"
   Solver=sat4j Bitwidth=4 MaxSeq=4 SkolemDepth=1 Symmetry=20
   560 vars. 72 primary vars. 907 clauses. 16ms.
   No counterexample found. Assertion may be valid. 2ms.

反例が見つからないということは、「『nextを辿った先が自分自身にはならない』ならば『nextは自分自身にはならない』」は正しいようです。不変条件の格上げは適切だと自信を持てました。

valueの不変条件を書く

ここまでで、全体の記述は以下のようになっています。

sig Stack {
    value: univ,
    next: lone Stack
}
fact NoRecursion {
    all s: Stack | s not in s.(^next)
}

run {} for 1 Int

runの結果……

value周りにおかしな関係が見つかりました。「Stack0.nextStack1」「Stack1.nextStack1」など、あるStackのvalueがStackだったケースでは循環が生まれてしまうようです。

その原因は、valueの属する集合が univ すなわち「あらゆるatomの集合」になっているせいです。

どのように対処しましょうか。

  • valueフィールドとnextフィールドの関係を適切に記述する
  • 「valueはStackにならない」という仕様で逃げる

理想的なのは前者ですが割愛します。腕に自信のある方は正面突破してみましょう。今回は「valueはStackにならない」という仕様を導入して逃げることにします。より厳密な日本語で表現するなら「すべてのStackについて、自身と関係するvalueはStackではない」というべきでしょうか。

assert TestValue {
    // ここに「すべてのStackについて、自身と関係するvalueはStackではない」という論理式を書く
}

もしあなたが手元にAlloyをインストールして進めているなら、ちょうどいい練習問題なので自分で書いてみましょうか。すこしスペースを空けますね。

...

...

...

...

...

答え合わせです。 答えのひとつは、次のとおりです。

assert TestValue {
    all s1, s2: Stack | s1.value not in s2
}

check TestValue for 1 Int, 4 Stack で検査してみるといいでしょう。

論理式をリファクタする

実は all s1, s2: Stack | s1.value not in s2 はベストな解ではありません。よりシンプルに書くなら、

all s: Stack | s.value not in Stack

で十分です。

そういうことなら…と書き換える前に、一度立ち止まりましょう。プログラミングでもそうですが、リファクタリングでは修正前後に記述の意味が変化してしまってはいけません。論理の世界の言葉で言い換えると「修正前の論理式と修正後の論理式が同値である」ことを確かめたいです。

そこで、式変形前後での同値性の確認のテクニックを紹介します。手順は先述した含意の確認とよく似ています。

assert TestTrans {
    (all s1, s2: Stack | s1.value not in s2)
        iff (all s: Stack | s.value not in Stack)
}

iffif and only if の略で「Bのときに限りAが成り立つ。Aが成り立つのはそのときに限る」つまり必要十分条件を示します。そのような論理式は同値として扱えます。オペレータとして <=> と書くこともできます。

check TestTrans をExecuteすると、コンソールに以下のメッセージが表示されました。

Executing "Check TestTrans"
   Solver=sat4j Bitwidth=4 MaxSeq=4 SkolemDepth=1 Symmetry=20
   1158 vars. 69 primary vars. 2196 clauses. 7ms.
   No counterexample found. Assertion may be valid. 3ms.

反例が見つからないということは、修正前後の論理式は同値だということです。より大きなスコープで反例が見つかる可能性を頭の片隅に常に置いておく必要がありますが、今回は大丈夫でしょう。自信を持って書き換えましょう。なお、もし反例があった場合にはVisualizerでグラフとして確認可能です。Alloyは便利ですね。

完成したモデルを確認

sig Stack {
    value: univ,
    next: lone Stack
}
fact NoRecursion {
    all s: Stack | s not in s.(^next)
}
fact NoRecursiveValue {
    all s: Stack | s.value not in Stack
}

この記述が適切なものか確認しましょう。

run {} for 1 Int, 4 Stack

よさそうです。

push, popの前後関係をpredで表現する

データ構造のStackに対しては、pushとpopという操作が可能です。その確認もしたいです。

Alloyの状態モデルではミュータブルな手続きを書くことはできません。かわりに操作前後の状態を別物(事前状態、事後状態)として扱います。事後状態は慣習的に ' をつけて表記します。

  • push前のStack = s
  • push後のStack = s' (読みはs-prime)

s になんらかの値をpushした状態」と「s'」が同値だと示す論理式を書けば、前後の関係が表現できます。

任意個数の変数から真理値(true/false)を導く論理式を述語(predicate)といいます。これまで書いてきた run {}{} も、実は述語です。このrun命令は、次のように分解できます。

pred P {} // 述語の宣言と定義
run P     // 定義した述語に対する出力命令

pushを表現したいなら、次のような三変数述語になります。なお改行はandを示します。

pred Push[s, s': Stack, x: univ] {
    s'.value = x
    s'.next = s
}

この述語を満たす関係を run Push for 1 Int, 4 Stack でビジュアライズすれば、push前後のStackの関係が読み取れます。

上図の表記は、以下を意味します。

  • $Push_sPush の変数の s
  • $Push_s'Push の変数の s'
  • $Push_xPush の変数の x

sx をpushした結果が s' になることが図示できていますね。

popについても同じように書けます。難しいところはないので特に説明しません。練習問題として試してみるとよいでしょう。

まとめ

AlloyでStackを記述・検査する一連の流れを通じて、Alloyの使い方を紹介しました。とっつきやすいツールですので、是非ご活用ください。

仕様が妥当なものか確認せずに、手続き的に複雑な仕様を作り上げてしまうことはよくあります。形式仕様記述を用いて仕様をシンプルに書き、ときにリファクタしながら研ぎ澄ませると、より安全で自信に満ちあふれた開発プロセスが手に入るかもしれません。

なお、本記事よりも高度な記述が知りたい場合は公式のlanguage referenceをご参照ください。 https://alloytools.org/download/alloy-language-reference.pdf