DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

Pythonエンジニアが感じたRailsの戸惑いポイント

この記事ははじめてのアドベントカレンダー Advent Calendar 2023 21日目の記事です。

初めまして。 2023年10月にDIGGLEにエンジニアとして入社したaki0344です。

DIGGLEではバックエンドにRuby on Railsを利用しています。
そこで今回は、小学生の娘に『ルビィのぼうけん』を買ったぐらいしかRubyとの関わりのなかった私が、Ruby on Rails(以下Rails)を学習する過程で感じた内容について書いてみたいと思います。
また、それだけだとただの感想文になってしまうので、Rails初心者をチームに受け入れるためにこんな用意をしておくとキャッチアップが早まるかもしれませんというポイントも書きたいと思います。

前提

本題に入る前に、私のバックグラウンドについて書いておきたいと思います。
DIGGLE入社前は中小独立系SIerでバックエンドエンジニアとして働いていました。
業務で利用していた言語は直近から順におおよそ以下の通りです。

  • Python(5年)
  • Java(10年)
  • C(1年)
  • C++(2年)

DIGGLEに入社するにあたり、主にProgate、およびRuby on Railsチュートリアルで学習を進めました。
上記のJavaから下に記載した言語は業務から離れて久しいため、今回は直近のPythonのメジャーなフレームワークDjangoと比較した際のRails特有の書き方にフォーカスを当てていきたいと思います。

Railsの便利な点

まずはDjangoと比較して便利だと思った点を書いていきます。

MVCモデル

RailsはMVCモデルでの開発を行うためのフレームワークとなっています。
DjangoもMVCモデルに似たMTVモデルを採用しており、Djangoでの開発経験があれば比較的スムーズにRailsにも入っていけるのではないかと感じました。
また、次の項目にも書く通り、Djangoよりもより厳格な運用となっており、プロジェクトによる差異が起きにくい仕組みとなっています。
そのため、一度Railsによる開発を経験すると、Railsで開発を行っている他プロジェクトに移った場合でもDjangoに比べてキャッチアップが早いのではないかと思います。

ディレクトリ構成に強力な制限がある

Railsはmodels/views/controllers/helpers等、プロジェクトを作成した時点で役割の決まったディレクトリが多く作られます。
それぞれのディレクトリに格納されたファイルが何をするかが明確に決められており、それに従って実装を行うのが最も効率的になるような設計になっていると感じました。
Djangoの場合、プロジェクト作成時にファイルは数個しか作られません。
開発プロジェクト毎にどこに何を置くかを決めて実装を行っていくことになります。
したがって、効率的な開発が行えるかどうかは開発プロジェクトの設計に大きく依存することになります。

書くコード量が少ない

これは実際に開発を行ってから強く実感したことですが、他言語に比べてコード量が少なくなります。
他言語と比べて開発効率の面で優位性があると感じました。

Ruby/Railsの戸惑いポイント

次はPython/Djangoと比較して戸惑ったポイントを書いていきます。

とにかく多い記法

Rubyは大量の組み込みライブラリが用意されています。
PythonとRubyで同じ処理を実装した時の一般的な書き方で比較してみます。

まずはPythonで処理を書いてみます。

import random
random_numbers = [random.randint(1, 1000) for _ in range(10)]

# リストの中身を標準出力
[print(x) for x in random_numbers]

# リストの要素を2倍する
double_numbers = [x * 2 for x in random_numbers]

# リストから偶数のみを取り出す
even_numbers = [x for x in random_numbers if x % 2 == 0]

全てfor文を使って書くのが一般的かと思います。
Pythonのコードを書いたことがなくても、何となくやっていることがわかるのではないかと思います。

一方、同じ処理をRubyで書くと以下のようになります。

random_numbers = (1..1000).to_a.sample(10)

# リストの中身を標準出力
random_numbers.each { |num| p num }

# リストの要素を2倍する
double_numbers = random_numbers.map { |num| num * 2 }

# リストから偶数のみを取り出す
even_numbers = random_numbers.filter(&:even?)

each(要素を取り出して処理), map(処理結果の配列を返却), filter(処理結果が真となる配列を返却)と全て異なるメソッドが登場します。
さらに、偶数のみを取り出す式ではfilterに対して&:even?という、Rubyを知らないとなぜこう書くのか想像するのが難しい値が渡されています。
&:についてはQiitaの@kasei-san(かせいさん)様の記事に詳しく書かれているためここでは割愛しますが、今回の場合はrandom_numbersの要素に対してメソッドeven?で偶数かどうかを判定する、となります。
Rubyの場合はどのような処理をしたいかで最適な文法が変わってくるため、Pythonに比べて覚える内容が多くなります。

あなたは変数?メソッド?

Python、Rubyいずれも以下のような書き方が出来ます。

next_number = previous_number + index

それぞれの名前から前回値にインデックスを加算して次に使用する値とすることが想像できますが、Pythonの場合ここに出てくるのは全て変数となります。
Pythonはメソッドを変数に設定することも可能ですが、実行する際は必ず括弧()が必要となります。

def print_a():
    print("a")

var_print_a = print_a
print("b")                 # => b
var_print_a()              # => a

一方、Rubyの場合メソッドの括弧()を省略可能という仕様があり、ここを見ただけでは変数なのかメソッドなのか判断が出来ません。

def print_a()
  p "a"
end

var_print_a = print_a       # => a
p "b"                       # => b
var_print_a                 # ⇒ a ("var_print_a"という変数に入っている、print_aの戻り値"a"が表示される)

そのため、最初に書いた式はprevious_number, indexという変数を加算したのかもしれないし、メソッドの結果を加算した可能性もあります。
実際にそれぞれが変数なのかメソッドなのかは前後の処理を追って確認する必要があります。

returnを書かない

Pythonはメソッドから値を返却する場合、returnの記載が必須となります。
returnが無いメソッドは戻り値無しとなります。
Rubyの場合、メソッド内で最後に行った処理の結果が戻り値となるため、必ずしもreturnを記述する必要はありません。
途中で値を返却したい場合のためにreturnを書くことは可能ですが、何故か省略可能なところでは省略するのが一般的なようです。
一貫してreturnを明記する言語で開発を行ってきた私にとってはちゃんと想定通り動くんだろうかと漠然とした不安を感じる要素ではあります。

DBモデルが見えない

Djangoのmigration機能を利用している場合、パっと見でモデル構成が理解できるようなコードになります。

from django.db import models
from django.db.models import Q


class User(models.Model):
    # カラム定義
    first_name = models.CharField(128)
    last_name = models.CharField(128)
    age = models.IntegerField
    email = models.CharField(256)
    company = models.ForeignKey(Company, on_delete=models.CASCADE)
    created_at = models.DateTimeField()
    updated_at = models.DateTimeField()

    class Meta:
        constraints = [
            # ユニーク制約
            models.UniqueConstraint(
                fields=["first_name", "last_name"],
                name="fullname"
            ),
            # チェック制約
            models.CheckConstraint(
                check=Q(age__gte=18),
                name='age_gte_18'
            )
        ]

実際にDBにmigrateが実行されるとテーブルにはidのカラムが作成されますが、それ以外はモデルを定義したファイル(一般的にはmodels.py)で確認が可能です。

一方、Railsでもデフォルトではschema.rbにDBモデルの構成が保存され、こちらも可読性は高いです。
しかしながら、DIGGLEでは以前プロシージャ導入にあたり、ファイルをstructure.sqlに変更しています。

diggle.engineer

structure.sqlはDBインスタンスを作成するために必要な全てのSQLが記載されており、モデルを把握するためにこのファイルを利用するのは現実的ではありません。
トリガ、シーケンス、プロシージャ等DB固有の機能を利用する場合、Railsの標準機能ではモデルの可読性が下がってしまうというのが問題としてあると感じました。

gemの記法で完全に迷子

Pythonは一部の標準ライブラリを除いて、ライブラリを使用する際はimportが必要になります。
また、処理の中で利用する際はimportで宣言した名前を指定する必要があります。 Djangoのプロジェクトを作成した際に自動生成されるファイルurls.pyでさえ、Djangoの機能を利用するためにDjangoのライブラリをimportしています。

from django.contrib import admin
from django.urls import path

urlpatterns = [
    path('admin/', admin.site.urls),
]

ワイルドカード指定でimportすることは可能ですが、非推奨となっています。

from django.db.models import *

したがって、推奨された書き方をするとそのファイルで使用されるライブラリは必ずそのファイル内に同じ名前でimportされることになります。
このルールは一見すると開発効率が落ちるように映るかもしれませんが、そのライブラリの存在を知らない開発者が参画した場合に、自ら調査して解析するための足掛かりになります。
また、最近はIDEの進化によりいきなりライブラリを使用した処理を書き始めても、自動で補完してimportを追加してくれるため、importを書かなくても良いフレームワークと比較した際の開発効率の悪さも解消されつつあります。

一方のRailsでは、Bundlerを使えばbundleコマンドでインストールしたgemを勝手に読み込んでくれるという機能があります。
開発の際には煩わしいrequireを定義する必要が無くなるというメリットがありますが、gemを知らない者が処理を読み進めていると突然良く分からない名前のクラスが出現することになります。

my_password = BCrypt::Password.create("my password")

上記の例ではBCryptという固有の名称があるためまだ検索して確認することが可能ですが、factory_botのようにgemの機能を利用するための定義を書くファイルとそれを利用する処理を書くファイルが分割されている場合、処理を書くファイルから読み始めると唐突に一般的な単語1つが出現します。

user = create(:user)

こうなってしまうと、どう調べれば良いのか見当もつかず完全に行き詰ってしまいます。

キャッチアップを早めるための準備

最後に、Rails初心者がキャッチアップを早めるために、こんなものが用意されていると良いと思うものを書いておきます。

Lintの導入

そもそも導入していない開発プロジェクトの方が少ないかとは思いますが、Rails初心者にとっては記法を覚えるための良き指導者にもなると感じています。
DIGGLEではPR発行時のCIでrubocopによるチェックを行っていますが、今のところ私は毎回rubocop先生に怒られています。

モデル図

DBの構成を把握できるかはシステムを理解するスピードに直結すると私は考えています。
ridgepoleannotate等、モデルの可読性が上がるgemを利用する、プロジェクト自体に手を入れたくない場合はtbls等のツールを利用してモデル図の作成/更新が行われていると新規参画メンバーの理解度が上がるのではないかと思います。

主要なgemの使用例

個人的にはここが最も自己解決の難しい部分ではないかと思います。 使用頻度の高いものや処理を追うだけではgem全体の仕様の把握が難しいものについては、使用例等と合わせて一覧化されていると、解析の一助になるのではないかと思います。

We're hiring!

DIGGLE では共にプロダクトを開発してくれるエンジニアを大募集中です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。
カジュアル面談も実施しているので、気軽なお気持ちでご応募いただければと思います!

herp.careers

Rails7環境での最適なJavaScriptビルドツールの選択ガイド

この記事は Ruby on Rails Advent Calendar 2023 の20日目の記事です。

はじめに

Rails7は、JavaScriptのデフォルトツールとしてimport-mapsを採用しました。これにより、ブラウザから直接JavaScriptモジュールをインポートすることが可能になりました。また、Node.jsやWebpack、npm、Yarnの必要性がなくなりました。ただし、選択肢はこれだけではありません。例えばjsbundling-railsではトランスパイルやバンドルがサポートされています。

この記事では、Rails7環境下でのJavaScriptビルドツールについて、選択肢を明確にし、それぞれの特徴を解説します。

Rails7のJavaScriptビルドツールの比較

Rails7には、JavaScriptのビルドに使用できる複数のツールが用意されています。(参考) これらのツールはそれぞれ異なる特徴と利点を持っており、プロジェクトの要件に応じて選択することになります。

種類 特徴 使用シナリオ
import-maps Node.js不要で、JavaScriptモジュールをブラウザから直接インポート可能。 トランスパイルが不要なシンプルなプロジェクト向け。
jsbundling-rails Node.js要。esbuild、Bun(Node.js不要)、Webpack等を選択可。HMR*1は標準機能ではない。 Reactを含む複雑なフロントエンドのビルドが必要な場合、またはHMRを利用するプラグインを追加可能。
Shakapacker Webpackerの後継でHMRサポート。 高度なビルドプロセスとHMRを要するReactプロジェクト向け。
Sprockets CoffeeScriptやSCSS/Sassのトランスパイル。JSXやTypeScriptにはWebpackやBabelが必要。最小化・難読化も可能。 複数のツールと併用して、アセット管理を行う場合に適している。

選択のポイント:

  • Reactを利用する場合、import-mapsは適切ではありません。代わりにjsbundling-railsやShakapackerを検討してください。
  • Reactを使用せず、トランスパイルやバンドルが必要ないシンプルなプロジェクトの場合、import-mapsが適しています。
  • Sprocketsは、他のツールと併用してアセットの管理を行う場合に有効です。

ちなみに弊社ではRails7でReactを利用するので、その前提で選定を進めます。

jsbundling-rails vs Shakapacker

Rails7でReactを使用する際の主要な選択肢として、jsbundling-railsとShakapackerがあります。これらのツールは異なるアプローチを提供し、それぞれ独自の長所と短所があります。

Rails: Webpacker(Shakapacker)とjsbundling-railsの比較(翻訳)

jsbundling-rails

長所

  • シンプルな設定: jsbundling-railsは設定が簡単で、迅速にプロジェクトを立ち上げることができます。
  • 柔軟性: esbuild、Bun、Rollup.js、Webpackなど、複数のバンドラーをサポートしており、プロジェクトのニーズに合わせて選択可能です。

短所

  • HMRのサポート不足: 標準ではHot Module Replacement(HMR)をサポートしていませんが、プラグインを使用することで対応可能です。

Shakapacker

長所

  • HMRサポート: ShakapackerはHMRをネイティブでサポートしており、開発プロセスをより効率的にします。
  • Webpackerの後継: Webpackerユーザーにとってなじみ深く、既存のWebpackベースの設定やプラグインを容易に統合できます。

短所

  • 設定の複雑さ: Webpackに基づいているため、設定が複雑になる傾向があり、初心者にはやや敷居が高いです。
  • ビルド速度: Webpackを使用しているため、esbuildなどの新しいツールに比べてビルド速度が遅い場合があります。

弊社ではビルド速度なども勘案して、jsbundling-railsを選択しました。

jsbundling-railsのバンドルの選択肢

jsbundling-railsにはBun, esbuild, Rollup.js, Webpack の選択肢があります。

以下、Google Trendsでの検索結果になります。
(Bunについては別のものが対象になりそうなので、Google Trendsの対象から除外しました)

esbuildが最近少し検索が増えてきたというのと、Rollup.jsがほとんど検索されてないという傾向にあるようです。

それぞれについて以下になります。

Bun

Bunは、JavaScriptおよびTypeScriptプロジェクトのための高速なオールインワンツールキットです。これには、ランタイム、バンドラー、テストランナー、Node.js互換のパッケージマネージャーが含まれています。また、Node.jsの多くのAPIをネイティブに実装しており、Node.jsの代替として設計されています。

esbuild

esbuildは、JavaScript、TypeScript、CSSファイルなどを高速にバンドルするためのツールです。その主な特徴は、非常に高速なビルドとバンドルの速度で、多くの既存のJavaScriptバンドラーよりも速く動作します。

Rollup.js

Rollup.jsは、JavaScriptのモジュールバンドラーです。ES6モジュール構文を使用してファイル間の依存関係を解析し、それらを一つのバンドルにまとめます。効率的なTree shaking(不要なコードを除去する最適化手法)をサポートしています。これにより、最終的なバンドルのサイズが小さくなり、パフォーマンスが向上します。

Webpack

Webpackは、JavaScriptのモジュールバンドラーです。コードベースのモジュールをマッピングし、一つまたは複数のバンドルにまとめることで、ブラウザで使用できるようにします。

比較

Bun esbuild Rollup.js Webpack
ビルド速度 1倍 1.76倍 188倍 224倍
ES6
TypeScriptのトランスパイル
JSXのトランスパイル
Tree shaking ×

△としている所は単体での機能提供はなく、プラグインなどが別途必要になります。 ただ、できることの基本的な機能に差異は余りありません。

違いが出ているのはビルド速度でBun調べのパフォーマンス計測では、Bunを1倍とすると最大200倍以上の差が発生しているようです。ちなみにBunはZigベース。esbuildはgolangベースで書かれているそうです。

Bunがまだjsbundling-railsで採用されたばかりという所から様子見も含めて、今回は jsbundling-rails + esbuildの利用としました。

esbuild利用の補足

jsbundling-rails + esbuild + npmを利用している場合に ./bin/rails assets:precompile のコンパイル実行はエラーとなります。

理由は、jsbundling-rails内でビルド時にpackage-lock.jsonの存在確認をしていないからです。

def build_command
  return "bun run build" if File.exist?('bun.lockb') || (tool_exists?('bun') && !File.exist?('yarn.lock'))
  return "yarn build" if File.exist?('yarn.lock') || tool_exists?('yarn')
  raise "jsbundling-rails: No suitable tool found for building JavaScript"
end

github.com

バグっぽいのでプルリク出ていないかなと覗いてみたら、丁度出されたみたいでした。 なので今後は大丈夫そうです。 github.com

SprocketsとPropshaft

Sprocketsの後継としてPropshaftが出ています。PropshaftはSprocketsにあったトランスパイルなどの機能はjsbundling-railsなどの他のgemに任せ、配信に必要な最小限の機能だけ提供することで、シンプルに保とうというコンセプトのライブラリのようです。

まとめ

今回、弊社はRails7 + Reactで、jsbundling-rails + esbuildを採用しました。 ただし、プロジェクトの要件によって最適なツールは変わります。 また、この辺りのビルドツールは、実際に利用してみてはじめて分かるようなハマりポイントもあったりします。

この記事が、プロジェクトに適したツールを選択する際の一助となれば幸いです。

DIGGLEのエンジニアのchikugoがお送りしました。

We're hiring!

DIGGLE では共にプロダクトを開発してくれるエンジニアを大募集中です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。
カジュアル面談も実施しているので、気軽なお気持ちでご応募いただければと思います!

herp.careers

*1:Hot Module Replacementの略。ウェブアプリケーションの開発中にページの全体リロードをせずに、特定のモジュール(例えばCSS、JavaScriptのコード)をリアルタイムで自動更新する仕組み。

DIGGLEが行った今年の技術広報まとめ

技術広報 Advent Calendar 2023の16日目の記事になります。

はじめに

当ブログをはじめ、今年のDIGGLEは色々な技術広報活動を行ってきましたので、今年一年で何を行ったのかを振り返りたいと思います。

やったこと

今年一年で行ったこととしては、こんな感じになります。

ということで、早速それぞれの詳細を記載していきます。

RubyKaigi Platinum Sponsors & ブース参加

今年行った技術広報で一番の目玉は何といってもコレだと思います。 2023年1月当時、エンジニアがCTOを含めても7名しかいない中、半数以上の4名をアサインして参加してきました。

出しもの(シューティングゲーム)、当日の速報、振り返りと、それぞれ詳しく書いてありますので、気になった方は以下のブログも読んでいただけると幸いです。 また、後述で説明するイベント登壇でもRubyKaigiについて話をするなど一年を通してRubyKaigi関連ネタで擦りに擦ってきました。

diggle.engineer

diggle.engineer

diggle.engineer

イベント登壇

ここ数年、弊社エンジニアが登壇する機会がなかったのですが、今年は2本登壇することができました。年初に「今年は2つくらい登壇したい!」と、ムーンショットなことを私自身言っていた記憶があるのですが、まさか達成できるとは思いませんでした。そして、2本とも私が適任だろうということで、1年間で2本も登壇させていただけるとは夢にも思いませんでした。

イベントに登壇しないかと声を掛けてくださった方、ロジ周りを行ってくれたバックオフィスメンバー、当日聞きに来てくれた参加者の方などなど多くの方にこの場を借りて感謝申し上げます。

1. RubyKaigiスポンサーの裏話

各社の技術広報が明かす「RubyKaigiスポンサーの裏話」運営ノウハウやコミュニティへの想いというイベントで、RubyKaigiにスポンサーブースを出した際のことを話してきました。

弊社以外の登壇企業は有名企業ばかりで、普通のことを話しても埋もれる!という危機感から、「エンジニアだけでスポンサーブースを出したら大変なことになった」というインパクト重視なタイトルで攻めてみました。

イベント当日に「これを見に来た!」とコメントされている参加者の方がいらっしゃったのを見たときは非常に嬉しかったです。

登壇時の資料は以下になります。

speakerdeck.com

2. AWS Startup Meetup Online

上記登壇の2営業日後、AWS Startup Meetup Online ~ スタートアップの熱量を直接感じてみよう ~というイベントで、DIGGLEのアーキテクチャーについて話してきました。

時間の都合上、アーキテクチャの概要部分しか話せなかったので、もし興味を持っていただいた方がいらっしゃればカジュアル面談でお話させてください。

登壇時の資料は以下になります。

speakerdeck.com

動画も残っているようなので貼っておきます。 www.youtube.com

エンジニアブログ

昨年と同じく、今年も以下の目標で一年間走ってきました。

  • 毎月1本以上ブログを公開する
  • 12月はアドベントカレンダーに参加して、1人1本ブログを書く

まず、月1本のブログ公開についてですが、公開が少し遅れる月があったものの、月1本ペースで今日まで走ってくることができました。

そして、RubyKaigiのあった5月には、開催前日に出しもの(シューティングゲーム)についてのブログを公開し、翌日のイベント初日には当日レポートも公開!ということで、月に計2本公開することができました。 当日レポートは、イベント当日の隙間時間を縫っての執筆となり、かなりタイトな状況でしたが、実現してくれたメンバーには感謝しきりです。

あとは今月のアドベントカレンダー用のブログを1人1本書ききれるか次第ですが、弊社エンジニアのみなさん頑張っていきましょう!

what we useへの寄稿

what we useの中の方からお声がけいただき、技術的負債の解消をDIGGLEではどのように行っているかについて寄稿させていただきました。

画像素材は提供したのですが、アイキャッチや組織図に関しては先方側でめっちゃいい感じのものを用意していただきました。

私自身はブログと変わらない感じで執筆したのですが、文章やレイアウトの体裁を整えたり、良い感じのイラストを差し込むことで、こんなに完成度が高い記事になるんだということを勉強させていただきました。やはりプロは凄い!

whatweuse.dev

エンジニアインタビュー

HRチーム主導で動いてもらっているのですが、エンジニアインタビュー記事を公開しています。 既に2本公開されておりまして、今年度中にあと1本公開するべくエンジニアにインタビュー & 記事執筆を行っていただいています。

note.com

note.com

まとめ

今年はRubyKaigiへのスポンサー参加という(弊社の中では)かなり大きなイベントがありましたが、それ以外でも登壇や記事公開など色々とアウトプットできた1年だったかと思います。 技術広報は継続することが重要なので、息切れせず来年以降も継続して活動していければと思っています。

以上、zakkyでした。

プロパティベーステスト (Property Based Testing) を Ruby で書き雰囲気を味わう

2023 年 10 月 30 日に『実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう』(以下 実践プロパティベーステスト本)という本が出版されました。 プロパティベーステストというのは、テストの一手法なのですが、これまでとは違う範囲をカバーするテストです。 今回はそれを Ruby に適用するとどうなるか検証、また似ている既知との概念と対比して理解を深めました。

これは Ruby Advent Calendar 2023 15 日目の記事です。

実践プロパティベーステスト本は 2023 年 12 月現在、テストの一手法であるプロパティベーステストを理解することを主眼においた、日本語になっている唯一の商業本だろうと思っています。プロパティベーステストは、ユニットテストがそうであるように言語を問わない手法です。もちろん Ruby でもできます。

Ruby でも既にいくつかライブラリがあるようです

今回は、最近も更新されている点、README の参考文献に実践プロパティベーステスト本の原著が挙げられていた点をふまえて ruby-prop_check のほうを使いました。

TypeScript に馴染のある方は、TypeScript で実施された記事を書いた方がおられたので、こちらも参考になるかもしれません https://qiita.com/kiwa-y/items/354744ef7393d07a8928

プロパティベーステストとはどのような形式か

まずどんなものか触れてみましょう。 ruby-prop_check は Ruby のテスティングフレームワーク minitest や RSpec と統合して利用する例が公式の README に記載ありました。たぶん test-unit でもできると思います。ここでは RSpec を使いました。

このコードは https://gist.github.com/niku/3f445fa36d241724f84d2fecae6b5054 に置いてますので、お手元でも試せます。

今回、配列に入っている値の平均を取る、以下の関数について考えます。

# 全ての要素が整数の配列を引数にとり、その要素の合計を要素数で割った平均を整数で返す
def naive_average(array)
  array.sum / array.length
end

これに対応するプロパティベーステストは、

require 'rspec'

RSpec.describe "#naive_average" do
  G = PropCheck::Generators

  it "returns an integer for any input" do
    PropCheck.forall(G.array(G.integer)) do |numbers|
      result = naive_average(numbers)

      expect(result).to be_a(Integer)
    end
  end
end

となります。ふだんの rspec に存在するものと、見慣れないもの PropCheck::GeneratorsPropCheck.forall がいますね。

「任意の整数 ( G.integer ) を要素にもつ配列 ( G.array ) の全て ( PropCheck.forall ) は、 naive_average に引数として渡すと必ず Integer 型を返す」と読めたりするでしょうか。

実行は普通の RSpec と同じように bundle exec rspec naive_average_spec.rb といった形になります。実行してみると以下の結果が得られました

$ bundle exec rspec naive_average_spec.rb

#naive_average
  returns an integer for any input (FAILED - 1)

Failures:

  1) #naive_average returns an integer for any input
     Failure/Error: result = naive_average(numbers)

     ZeroDivisionError:

       (after 71 successful property test runs)
       Failed on:
       `[]`

       Exception message:
       ---
       divided by 0
       ---

       (shrinking impossible)
     # ./naive_average.rb:2:in `/'
     # ./naive_average.rb:2:in `naive_average'
     # ./naive_average_spec.rb:10:in `block (3 levels) in <top (required)>'
     # ./naive_average_spec.rb:9:in `block (2 levels) in <top (required)>'

Finished in 0.01036 seconds (files took 0.10241 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./naive_average_spec.rb:8 # #naive_average returns an integer for any input

71 回プロパティベーステストが成功した ( after 71 successful property test runs ) あとに、 [] を渡したとき ( Failed on [] ) に ZeroDivisionError で失敗したようですね。ひとまず 70 回テストするのに 0.01 秒で実施できるなら、100 回以上のテスト生成でも実用に耐えそうです。

さて、ほんとうに [] で失敗するでしょうか。確かめてみましょう。 リポジトリを Gist にするためにディレクトリ構成がいつもと違って lib/ にいないので -I . というのをコマンドに足していますが、プロパティベーステストには関係ないところなので、あまり気にしないでください。

確かに [] を渡すと divided by 0 (ZeroDivisionError) でエラーになっていますね。空配列は #length0 になるため 0 / 0 となり起きたようです。

$ bundle exec irb -I .
irb(main):001> require 'naive_average'
=> true
irb(main):002> naive_average([])
/Users/niku/src/property_based_testing_with_ruby_sample/naive_average.rb:2:in `/': divided by 0 (ZeroDivisionError)
        from /Users/niku/src/property_based_testing_with_ruby_sample/naive_average.rb:2:in `naive_average'
        from (irb):2:in `<main>'
        from <internal:kernel>:187:in `loop'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/gems/3.3.0+0/gems/irb-1.9.0/exe/irb:9:in `<top (required)>'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/bin/irb:25:in `load'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/bin/irb:25:in `<top (required)>'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli/exec.rb:58:in `load'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli/exec.rb:58:in `kernel_load'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli/exec.rb:23:in `run'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli.rb:492:in `exec'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/vendor/thor/lib/thor/command.rb:28:in `run'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/vendor/thor/lib/thor/invocation.rb:127:in `invoke_command'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/vendor/thor/lib/thor.rb:527:in `dispatch'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli.rb:34:in `dispatch'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/vendor/thor/lib/thor/base.rb:584:in `start'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli.rb:28:in `start'
        ... 5 levels...

さて、仕様では「全ての要素が整数の配列を引数にとり、その要素の合計を要素数で割った平均を整数で返す」ということを考えていました。空配列は「全ての要素が整数の配列」といえるでしょうか?言葉の意味的にどうかはさておき、このプログラムを提供する側と、利用する側で考えが異なるとトラブルになるので、未然に防ぎたいところです。プロパティベーステストは、こういった開発時に意識から外れている範囲のことに気づくきっかけを得られるのがいいところですね。

今回は仕様を「全ての要素が整数の、 要素を 1 つ以上持つ 配列を引数にとり、その要素の合計を要素数で割った平均を整数で返す」へと更新して、そのテストを行うことにします。

PropCheck::Generators.array をみると、こういった指定にもオプションで対応できるようになっています。 最低要素数、最大要素数、空配列を許可するか、ユニークであることが指定できそうですね。 G.array の引数に empty: false を追加して、以下のコードになりました。

require 'naive_average'
require 'prop_check'
require 'rspec'

RSpec.describe "#naive_average" do
  G = PropCheck::Generators

  it "returns an integer for any input except empty array" do
    PropCheck.forall(G.array(G.integer, empty: false)) do |numbers|
      result = naive_average(numbers)

      expect(result).to be_a(Integer)
    end
  end
end

実行しましょう。

$ bundle exec rspec naive_average_spec.rb

#naive_average
  returns an integer for any input except empty array

Finished in 0.017 seconds (files took 0.10255 seconds to load)
1 example, 0 failures

うまくいったみたいですね。 (デフォルトではテストは forall あたり 100 回実施する設定になっていました。n_runs という設定で変えられます

既知の概念との対比

ここらへん、私の理解や解釈が書いてあるので、まちがっているかもしれないです。そのときはやさしく教えていただけると感謝しつつ直します。

以下に列挙するように、他の概念と重なりつつも、異なるアプローチでソフトウェアを形づくろうとしています。だからこそ今までにない知見が得られて有用で、だからこそ今までの知見が使いにくく書きにくいのだと思っています。

普段書いているテストと、プロパティベーステスト

プロパティベーステストの立場にいるときに、ふだん私たちが行っているテストを何とよぶのが適切でしょうか。 英語だと "example based testing" と呼ぶようです、実践プロパティベーステスト本だと「事例ベースのテスト」など、事例ベースという表現の訳になっていました。

事例ベースのテストでは、具体的な事例を扱います。たとえば "abc" + "d""abcd" になるといった形ですね。

プロパティベースのテストでは、プロパティを扱います。プロパティというのは何でしょうか。1 章(p.8) には

「どのような入力値を与えても常に同じであるような振る舞い」を記述するルールを見つけて、それを実行可能なコードとして書き表すのです。

とありました。ここまで見てきたような、常に真となるようなルールを並べながら仕様を形作っていくことになります。 たとえば

  • (ascii なら) 足す前の左側、右側の文字数合計と、足した後の文字数は等しい
  • 左側の文字は、足したあとの文字に前方一致する。右側の文字は、足したあとの文字に後方一致する

などです。

1 章(p.11) には

事例ベーステストは「コードが自分たちの想定した通りに実行されるかを確信する」ための助けになり、プロパティベーステストは「プログラムに何ができて何ができないかを確認するためにその振る舞いを探索し、そもそも自分たちの想定が正しいかどうかを判断する」ための助けになります

とあり、私はこの例えがしっくりきました。プログラマの想像外を見つける手助けになるのがプロパティベーステストなんですね。助けになる種類が違うので、どちらかだけだけでなく、両方行うことが肝要だと感じました。

データ(テーブル)駆動テストと、プロパティベーステスト

test-unit だと、こんな感じに test_plus で利用する引数と期待する結果を定義して、一つのテストに複数のデータを流してテストできます。この例だと 4 = 3 + 1 と -1 = 1 + (-2) ですね。

    class TestDataSet < TestCalc
      data("positive positive" => {:expected => 4, :augend => 3, :addend => 1},
           "positive negative" => {:expected => -1, :augend => 1, :addend => -2})
      def test_plus(data)
        assert_equal(data[:expected],
                     @calc.plus(data[:augend], data[:addend]))
      end
    end

Go でも公式の Wiki に項目があるくらい有名な手法です。

var flagtests = []struct {
    in  string
    out string
}{
    {"%a", "[%a]"},
    {"%-a", "[%-a]"},
    {"%+a", "[%+a]"},
    {"%#a", "[%#a]"},
    {"% a", "[% a]"},
    {"%0a", "[%0a]"},
    {"%1.2a", "[%1.2a]"},
    {"%-1.2a", "[%-1.2a]"},
    {"%+1.2a", "[%+1.2a]"},
    {"%-+1.2a", "[%+-1.2a]"},
    {"%-+1.2abc", "[%+-1.2a]bc"},
    {"%-1.2abc", "[%-1.2a]bc"},
}
func TestFlagParser(t *testing.T) {
    var flagprinter flagPrinter
    for _, tt := range flagtests {
        t.Run(tt.in, func(t *testing.T) {
            s := Sprintf(tt.in, &flagprinter)
            if s != tt.out {
                t.Errorf("got %q, want %q", s, tt.out)
            }
        })
    }
}

このテスト形式と、プロパティベーステストは、一つのテストに対して多数のデータを入力して検証するというところは似ています。 ただそれでも、データ駆動テスト形式はあらかじめプログラマが定めた値そのものを検証するという部分がプロパティベーステストとは異なります。

ファジングと、プロパティベーステスト

Go には標準機能でファジングが行え、テスティングフレームワークに統合されるぐらい、ファジングは市民権を得ているようです。 ファジングと、プロパティベーステストはどう違うのでしょうか。https://go.dev/doc/tutorial/fuzz#write-the-code-2 を読むと、

The unit test has limitations, namely that each input must be added to the test by the developer. One benefit of fuzzing is that it comes up with inputs for your code, and may identify edge cases that the test cases you came up with didn’t reach.

「unit テストは開発者によって入力が加えられなくてはならないのに対し、ファジングはコードへの入力を生成してくれる」とあり、似ているように見えます。

わたしもここの理解があやふやな所もありますが、値生成によるテストで検証するという手法は同じでも、ファジングはセキュリティの担保や脆弱性の検知といったように、壊れない頑健さを確かめるもので、プロパティベーステストは期待の動作が行われることを確かめるものと、目的が異なるのかなと思っています。

プロパティベーステストではファジングのようなことも行うので、プロパティベーステストの方がより広い概念を扱っていると言えそうです。

訳者まえがきにも

なお、プログラムがクラッシュするよう なテストケースに関して「ファジング」と呼ばれる手法を耳にしたことがある方もいるかもしれませんが、プロパティベーステストはファジングよりも広範囲のシナリオで活用できる仕組みといえます。

とありました。

型と、プロパティベーステスト

最初に挙げていた例の「全ての要素が整数の、要素を 1 つ以上持つ配列を引数にとり、その要素の合計を要素数で割った平均を整数で返す」は静的な型で表現できそうです。 強い制約を持つ静的な型があれば、プロパティベーステストの意義というのは低いでしょうか。 確かにこの本のターゲットである Erlang, Elixir 共に動的型付言語ですし、Ruby もそうですね。

実際には、プロパティベーステストは Haskell の QuickCheck から発生したもので、強い制約を持つ静的な型があるシステムでも有用です。 私が最初に挙げていた例は、簡素化のために型で表現していましたが、プロパティベーステストは型をチェックする他にも様々な検証方法があります。 検証方法は、本の 3 章「プロパティで考える」を読むと理解が深まります。

強い制約を持つ静的な型があれば、プロパティベーステストの一部は省略できるけれども、プロパティベーステストはより広い範囲の事象を扱うと言えそうです。

まとめ

  • プロパティベーステストを Ruby に適用するとどうなるか、コードを動かしながらお知らせしました
  • プロパティベーステストと似ている既知の概念と、プロパティベーステストはどこが同じで、どこが違うのかお知らせしました

DIGGLE でもプロパティベーステストを導入して役に立つか検証を行う段階です。 Ruby に限らず、フロントエンド側の TypeScript でも始めようとしています。 もしこの記事を読んで興味を持った方がおられれば、一緒にやりませんか。

ご応募をぜひご検討ください。お待ちしています!

herp.careers

一人PdMでもできる!「事業開発」はじめの一歩

BtoB事業開発アドカレの記事です

このnoteは「BtoB事業開発アドカレ 」の8日目の投稿です。面白かったらハッシュタグ「 #BtoB事業開発アドカレ 」を付けてシェアいただけますと幸いです。 前回は、問いを磨くこと #BtoB事業開発アドカレ|mai@株式会社iCAREでした。

adventar.org

想定読者

  • SaaS立ち上げフェーズでPdMとして活躍されており、全てを馬力で回しているそこのあなたに向けた記事です。
    • 事業開発したい。でも、開発伴走するのに追われて中々手が出せないなー、、と思っている方には特におすすめ。
  • すでに事業開発が仕組み化、チーム化されている皆様には初歩的すぎて味気ないかもしれません、ごめんなさい。

サマリ

  • PdMは顧客と遠くなりがち、一番顧客に近いCS,Salesの声をいかに拾えるか仕組みづくりが大事
  • ビザスクは軽い気持ちでいろんな人の話が聞けていいぞ!

ちょろっとだけでも是非読んでみてください↓

はじめに

こんにちは、DIGGLE株式会社でPdMをやっている本田です。 現状PdMというロールは私のみで担っている形になっています。ありがたいことに直近ご利用いただいている会社数も右肩上がりに伸びており、忙しい日々を過ごさせてもらってます。

そんな「一人PdMあるある」として、足元の開発デリバリーをデザイナー、開発者と伴走することに追われてしまい中々先を見据えた仕込みができなくなってしまう、、、 みたいなことがあると思っています。 私自身が半年くらい前に事業開発に積極的に関わっていきたい!けど時間がない、、というジレンマに陥っていました。

ただ、やはり事業フェーズ的にも事業開発めちゃ大事!!ということを感じたため、半年前くらいから勇気と工数を振り絞ってチャレンジしてみると、様々な取り組みから成果が生まれ始めました!

今回は「事業開発はじめの一歩」というところで、自分が最近トライをしてみた「コスパの良い」、「スモールスタートな」事業開発について少しご紹介させていただければと思います。

ホリゾンタルSaaSにおける「事業開発」とは

この後の話の前提をすり合わせるために、私が今回話す上での「事業開発」について説明します。

我々はホリゾンタルSaaSというところで、あらゆる業種/業態、規模の「予実管理」におけるペインを解決していくSaaSになっています。

予実管理は非常にカスタマイズ性の高い領域になっており、業種/業態、規模によってペインや勘所も微妙に異なってきます。 したがって、「プロダクトの刺さり具合(価値を感じていただけるか)」の度合いは各セグメントごとによって変化してきます。

その中では、「現状の獲得できているセグメント」から「新たにどのセグメントに注力していくか」が大事になっていきます。 今回の「事業開発」は「新たにどのセグメントに注力していくか」をどの方向性にするか決める、実行するということについて話しているということを前提におきたいと思います。

事業開発何から始めたか

①CSチームで行っていたサクセス/チャーン顧客分析MTGに参加

まずは基本の「き」となる部分かもしれませんが、現状顧客をしっかりと知るところからスタートしました。

具体的には、サクセス移行、チャーン移行したお客様の経緯などを分析する座組みをCSチーム内でされていたのですが、そこにお邪魔させていただき担当CSとのディスカッションを行いました。

ここでのポイントとしては、お客様の時間をいただいて直接インタビューするのではなく、担当CSを介した情報を利用するという点です。 一次情報ではないため、多少のバイアスがかかった情報であることを前提に置く必要はあります。しかし、大まかな仮説を考えていく上では短時間でまとまった情報が仕入れられるというメリットがあります。

それぞれの会でヒアリングする方向性は若干異なります。サクセス分析では「勝ち筋」、チャーン分析では「負け筋」の探索をするように意識しています。

N=1の事象なのでつい具体的な良かった、ダメだった施策に目が行きがちですが全体感を捉えることを重要視しています。 具体的には、下記のようなポイントを聞く際には意識しています。

②お客様からのポジティブなフィードバックを収集するチャンネル作成、運用

2つ目の取り組みとしては、ポジティブなフィードバックのデータを収集し始めました。 「フィードバック」というと要望(例.OO機能が欲しいなど)などの比較的ネガティブ寄りなものが一般的に収集されやすい結構にあります。

しかし、先述したように「勝ち筋」を探索する上ではお客様に刺さっている場所を特定することも大事だと考えています。

①のサクセス移行分析だけでは、限定的なお客様が対象となってしまいます。より広範囲でライトにフィードバックをいただくために 「#20-product-voice_of_customer」というチャンネルをslack上に作成しました。

こちらも実際の一次情報ではないため、フィードバックを受けた背景情報など、バイアスがなるべく無いよう見極める必要もあります。しかし、「こんな刺さり方もあるのか!」など意外な気づきもあり、新たな仮説を生み出すことにつながっています!

↓実際のSlackチャンネル(お見せできない情報ばかりで恐縮ですが、、)

③ビザスクを利用して、スポットコンサルを依頼する

最後にスポットコンサルを依頼できる「ビザスク」を依頼し、「ある業界に詳しい方」にインタビューをするということを行いました。 ①、②で得た粗い仮説をもとにより深掘りたい話や、仮説をもとに壁打ち的に検証したいことなどをスピーディに検証したいときにに利用させていただいています。(回し者ではございませんw)

特に我々のようなBtoBサービスにおいては、お客様以外のターゲット層へのインタビューの難易度はBtoCサービスよりも高いということもあり、募集-->日程調整のフローで行えるこの方法を重宝しています。

まとめ

このような3つの具体を実践してみることで、PdMの日常業務を行いながら中長期に向けた仕込みを少しずつ進めることができています。 「事業開発」というと名前からして、とっつきにくい、大変そうといったイメージがある方もいると思います。

しかし実際には、、

  • そもそも社内には事業開発の種になる情報がたくさん落ちているけど意外と拾いきれていない

  • また社外のリソースをうまく活用することで、コストを低く、社内にない情報を部分的に拾い切れる

ということからやり方次第でスモールスタートができるというのが今回の自分の学びでした。

一方で、今回の方法は手早く始められるのですが、深さは足りていないなと感じているところもあります。今後より精度を上げた事業開発を行うにあたっては時間も取組も増やしていかなければいけないと思っています。

また、今回の自分の取り組み内容として上げたものはCSやSalesのメンバーがお客様と密に接しているという前提があったり、それを積極的にフィードバックしてくれる文化があってこそだと社内メンバーへの感謝を改めて感じました。

最後の最後に

私が入社した2022年8月は30人弱だった組織は1年強で50人を超えそうな勢いになっています。 ありがたい悲鳴ですが、それでもまだまだ全然人が足りていません!プロダクトマネージャー領域は戦略から実行まで超少人数制でやっており、最近手が回ってきていない箇所が顕在化するようになってきてしまいました。

ということで全職種全方位採用強化中です!! さらなる成長を止めないために、皆さんのお力を貸してください!よろしくお願いいたします!!

herp.careers

herp.careers

herp.careers

Ruby の oneshot coverage で本番稼働中の Rails アプリの使用状況を収集して不要なコードを発見するための仕組みを導入した話

前置き

プロダクト開発は人の手によって行われるものですから、開発サイクルの中で不要なコードを削除し忘れる人的なミスはどうしても発生します。

後から不要なコードに気づき削除する際には、慎重にチェックして本当にコードが使用されていないことを確認する必要があります。ただし、削除に自信が持てない場合、本番稼働中の Rails アプリに障害が発生する可能性も考慮しなければならず、何も影響しない場合はそのままにしておくことも多く、削除が難しい状況も少なくないと思います。

上記状況を打破するために DIGGLE では本番稼働中の Rails アプリのコードの使用状況を収集して不要なコードを発見するための仕組みを導入したので共有したいと思います。

不要なコードをどのように発見するか

不要なコードを発見するには、コードの実行状況を把握する必要があります。そのために利用される手法の1つに「カバレッジ」があります。カバレッジは、プログラム内の各行がどれだけ実行されたかを測定する指標であり、未使用のコードや不要なコードを見つけるのに役立ちます。

Ruby で コードカバレッジを計測する

Ruby には、処理通過した行数を記録する oneshot coverage という仕組みがあります。

oneshot coverage は Ruby 2.6 で追加された機能で、これまでのコードカバレッジの測定方法とは異なり、「各ソースコード行を1回でも実行したか否か」を記録します。そのため、1度実行された行に関しては、以降の実行でオーバーヘッドが発生せずに処理される特徴があります。

このようなコードカバレッジ測定の仕組みを本番稼働中の Rails アプリに導入する際はオーバーヘッドに懸念があると思いますが oneshot coverage ではその点においても問題がなさそうです。

oneshot coverage を使ったカバレッジの測定方法についての詳細は、以下の記事が参考になりました。

Ruby 2.6 新機能:本番環境での利用を目指したコードカバレッジ計測機能 - クックパッド開発者ブログ

仕組み導入にあたって悩んだポイント

YJIT を有効化した状態で動作するのか

DIGGLE は Ruby 3.2 系 で YJIT を有効化した状態で動作しているため、YJIT を有効化した状態で oneshot coverage が動作するのか という一時的な懸念事項がありましたが、動作検証により問題がないことを確認しました。

oneshot coverage 単体での運用は難しそう

当初は、Ruby の oneshot coverage 単体での運用を想定して進めていましたが調査を進める中で、以下のポイントに悩みました。

  • 計測結果の収集タイミング や 保存先 はどうするか
  • コードに変更が入った場合はどうするか
  • 収集したコードの使用状況を確認するUIはどうするか

oneshot coverage の仕組みを導入するとしてもひと工夫が必要そうだという話になりました。

また、自分たちでゼロからこの仕組みを構築するには時間がかかってしまいますし、ライブラリが存在していれば、運用する上でのハマりどころも対策されていることがあります。

最終的には上記の悩みポイントが解消されている Coverband というライブラリを導入して実現することとなりました。

既存のライブラリを使うという選択をしたため、今回のブログでは Coverband の導入方法に関しての文章は少なめで、導入するまでに調査したことの文章は多めとなっています。

不要なコードを発見する仕組み導入の参考になると嬉しいです。

Coverband の導入検討時に気になったポイント

本番稼働中のコードカバレッジを計測するためのライブラリに Coverband があります。

GitHub - danmayer/coverband: Ruby production code coverage collection and reporting (line of code usage)

Coverband では oneshot coverage もサポートしています。

Oneshot coverage support for ruby 2.6 · Issue #154 · danmayer/coverband · GitHub

先程挙げた悩みポイントを Coverband ではどのように解消しているか気になったので調査を行いました。

計測結果収集の頻度はどうするか

Coverband では設定の background_reporting_sleep_seconds がバックグラウンドレポート(計測結果)の収集頻度を決める設定になっていました。

  • ./config/coverband_service.rbを使用する場合で、本番環境の場合は600秒、それ以外の場合は60秒。
  • Coverband::Adapters::HashRedisStoreを使用する場合は、300秒。
  • それ以外の場合は60秒。
# see: https://github.com/danmayer/coverband/blob/v5.2.5/lib/coverband/configuration.rb#L116-L129

    # The adjustments here either protect the redis or service from being overloaded
    # the tradeoff being the delay in when reporting data is available
    # if running your own redis increasing this number reduces load on the redis CPU
    def background_reporting_sleep_seconds
      @background_reporting_sleep_seconds ||= if service?
        # default to 10m for service
        Coverband.configuration.coverband_env == "production" ? 600 : 60
      elsif store.is_a?(Coverband::Adapters::HashRedisStore)
        # Default to 5 minutes if using the hash redis store
        300
      else
        60
      end
    end

計測結果収集の頻度については、Coverband の background_reporting_sleep_seconds のコメントに記載されていました。

保存時のサーバー、Redis への負荷と計測結果が反映されるまでの時間とのトレードオフとのことでした。

「蓄積した計測結果をたまに参考するという使い方」になりそうだったので本番環境で600秒ごとに計測結果をレポーティングする設定としました。

計測結果の保存先はどこにするか

本番環境で運用する際にどこに保存されるかが気になったので調査しました。

Coverband では、Redis に保存します。

https://github.com/danmayer/coverband#redis

以前は、Coverband の保存先の選択肢として S3 もあったようですが Coverband 5.0.0 にバージョンアップしたタイミング で S3 サポートは終了していました。

https://github.com/danmayer/coverband/blob/main/changes.md#coverband-500

DIGGLE の本番環境では ElastiCache for Redis を利用しています。

Redis は揮発性のため電源断の障害などが発生した場合にデータが失われる可能性がありますが、データが失われた場合もコードカバレッジを再度取り直せば大丈夫という運用を想定しているため、こちらも大きな問題とはなりません。

コードに変更が入った場合は計測結果をどうするか

oneshot coverage では、計測結果として以下のような形式のハッシュを返します。

{ "ファイル名" => { :oneshot_lines => [実行された行番号の配列] }, ... }

例えば、次のような test.rb を用意します。

1: # test.rb
2: def foo(n)
3:   if n <= 10
4:     p "n <= 10"
5:   else
6:     p "n > 10"
7:   end
8: end
9:
10: foo(1)
11: foo(2)

test.rbを実行したときの計測結果を取得します。

$ require "coverage"

# カバレッジ測定開始 (oneshot_lines モードにする)
$ Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込む
$ load "test.rb"

# 結果の取得
# clearキーワード:新たに実行された行番号の結果のみを取得する
# stopキーワード:カバレッジの測定を停止しない
$ p Coverage.result(clear: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11]}}

では、同じファイル名で中身を以下のように書き換えると計測結果はどうなるでしょうか。

1: # test.rb
2: def hoge
3:   puts "hoge"
4: end
5: 
6: hoge
# 測定対象のプログラムを読み込む
$ load "test.rb"

# 結果の取得
# clearキーワード:新たに実行された行番号の結果のみを取得する
# stopキーワード:カバレッジの測定を停止しない
$ p Coverage.result(clear: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 6, 3]}}

ファイル名は同じですが、実行された行番号の結果が変わります。

この結果を何も考えずに古い計測結果にマージしてしまうと参考にならない計測結果になってしまいます。

このようにファイル名は同じでも中身が変わっている場合には、以前の計測結果を捨てて新しい計測結果を使うといったことを考慮する必要が出てきます。

Coverband では、レポートを保存する際に、ファイルごとに以下のようなマージ処理を行っているようでした。

  • 新規と既存の計測結果が存在しており、ファイルハッシュが同一の場合は新規の計測結果と既存の計測結果をマージする
  • 上記以外で 新規の計測結果のみ存在する場合 や 新規と既存の計測結果が存在するがファイルハッシュが同一ではない場合、新規の計測結果を採用する
  • それ以外の場合は、既存の計測結果を採用する
# see: https://github.com/danmayer/coverband/blob/v5.2.5/lib/coverband/adapters/base.rb#L116

      def merge_reports(new_report, old_report, options = {})
        # transparently update from RUNTIME_TYPE = nil to RUNTIME_TYPE = :runtime
        # transparent update for format coveband_3_2
        old_report = coverage(nil, override_type: nil) if old_report.nil? && type == Coverband::RUNTIME_TYPE
        new_report = expand_report(new_report) unless options[:skip_expansion]
        keys = (new_report.keys + old_report.keys).uniq
        keys.each do |file|
          new_report[file] = if new_report[file] &&
              old_report[file] &&
              new_report[file][FILE_HASH] == old_report[file][FILE_HASH]
            merge_expanded_data(new_report[file], old_report[file])
          elsif new_report[file]
            new_report[file]
          else
            old_report[file]
          end
        end
        new_report
      end

Coverband ではファイルハッシュは Digest::Base クラスの file メソッドを使ったハッシュ値を使用していました。

# ファイルハッシュは `Digest::Base` クラスの file メソッドを使ったハッシュ値を使用
# see: https://github.com/danmayer/coverband/blob/v5.2.5/lib/coverband/utils/file_hasher.rb#L8

      def self.hash(file, path_converter: AbsoluteFileConverter.instance)
        @cache[file] ||= begin
                           file = path_converter.convert(file)
                           Digest::MD5.file(file).hexdigest if File.exist?(file)
                         end
      end

先程例にあげたtest.rbで変更前と変更後のハッシュ値の変化を見てみます。

# 変更前
$ Coverband::Utils::FileHasher.hash('test.rb')
"da3c8fd8b579b3d26d4535aa7afb703d"

# 変更後
# rails c し直す必要がある
$ Coverband::Utils::FileHasher.hash('test.rb')
"2d394cc51d14fa5eeb0f44ab2f11ca55"

このように Coverband ではファイルの中身が変更されたかどうかの判定する際に、ファイルハッシュを利用することで処理を切り分けていました。

正しい計測結果を参照するための工夫がされており、実運用面でも問題なさそうです。

計測結果をどのように表示するか

Coverband には Web UI が提供されており、全体的なカバレッジ情報の確認や個々のファイルの詳細を確認することができます。

https://github.com/danmayer/coverband/tree/v5.2.5#coverband-web-ui

Coverband より引用

個々のファイル詳細では視覚的に未使用のコード行を確認することができます。

個々のファイル詳細

ルーティングを追加する必要があるため、その場合は適切な認証を行う必要があります。

https://github.com/danmayer/coverband/tree/v5.2.5#mounting-as-a-rack-app

このように Coverband では、本番環境中の Rails アプリの使用状況を収集して有意義に活用するための UI も提供されており実運用面で困ることはなさそうです。

Coverband 導入時に苦労したポイント

Coverband の導入自体はドキュメントに沿って行うことで比較的スムーズに導入できました。

そのため、基本的な導入方法については Coverband のドキュメントを参照していただければと思います。

ここでは、苦労したポイントに絞って共有したいと思います。

環境変数が設定されるより前に Coverband が呼び出されてしまう

DIGGLE では、環境変数を管理する dotenv という gem を使用しています。

dotenv の初期化処理が呼ばれるより前に Coverband が呼び出されてしまうため、環境変数が存在せずエラーになるということがありました。

dotenv のドキュメントを参考に Coverband が呼び出される前に dotenv を手動で呼び出す処理を追加することで解決しました。

https://github.com/bkeepers/dotenv#note-on-load-order

if Rails.env.development? || Rails.env.test?
  Dotenv::Railtie.load
end

テスト実行時に Coverband が動作してしまいエラーが出る

Coverband は本番環境でコードの使用状況を収集する目的で使用するため、テスト環境では不要でしたがどうやらテスト実行時にも動作してしまうようでした。

Coverband: detected SimpleCov in test Env, allowing it to start Coverage
Coverband: to ensure no error logs or missing Coverage call `SimpleCov.start` prior to requiring Coverband
E, [2023-10-02T10:54:05.655447 #39] ERROR -- : coverage failed to store
E, [2023-10-02T10:54:05.655896 #39] ERROR -- : Coverband Error: #<NoMethodError: undefined method `each_with_object' for nil:NilClass> undefined method `each_with_object' for nil:NilClass
.
.
.
略

下記を参考に Coverband を 特定の環境 のみ読み込むようにすることで解決しました。

※本来は本番環境のみ読む込む形でよいと思いますが、開発環境やステージング環境でも動作確認がしたいため Coverband を読み込むようにしています。

Explicitely disable coverband without an ENV var? · Issue #449 · danmayer/coverband · GitHub

# Gemfile

gem 'coverband', require: false # 環境毎に有効/無効を設定するためロードしない
# config/application.rb

# 明示的にテスト環境ではCoverbandを無効にする
require 'coverband' if Rails.env.production? || Rails.env.staging? || Rails.env.development?
# config/coverband.rb

if defined? Coverband
  # ここに設定
end
# config/routes.rb

  if defined? Coverband
      # ここにルーティング設定
  end

まとめ

記事では、導入する際に悩んだポイントやそれに対する解決策として Coverband を導入した経緯を紹介しました。

既存のライブラリを有効活用することができたので自分たちでゼロから仕組みを構築する場合に比べてスムーズに導入することができました。

本番環境での活用はもう少し先となりますが上手く活用してプロダクトのコードの見通しの良さに貢献できると思うと楽しみです。

開発のお手伝いをさせていただいている株式会社 diddyworks の sano がお送りしました。

運用改善につなげるための Terraform ディレクトリ構成 〜 Modules を用いてリソース定義を DRY にした話 〜

DIGGLE ではインフラのコード管理(IaC: Infrastructure as Code)のツールとして Terraform を利用しています。

www.terraform.io

私たちの開発組織では「自動化・効率化できるものはなるべくして、人的ミスや工数の無駄を無くすこと」を大切にしており、そのための手段として Terraform が有力であると考えているためです。

Terraform のコードを書く上で悩ましいポイントの一つにディレクトリ構成があると思います。 DIGGLE ではつい最近、メンテナンス性の向上を目的にディレクトリ構成の見直しを行いました。 本記事では、その見直しの内容について紹介します。

前提

はじめに前提情報として、DIGGLE のインフラ環境を共有します。

  • 運用してるプロダクトは一つ
  • AWS を用いてインフラ環境を構築
    • AWS以外のサービスはTerraformで管理していません
  • 本番環境の他に検証環境(ステージング環境)が複数存在
    • 全ての環境が Terraform による管理の対象

上記となっています。

従来のディレクトリ構成

見直し前のディレクトリ構成は以下のようになっていました。

aws
 ├ production    # 本番環境用のリソース定義
 │  ├ main.tf
 │  ├ variables.tf
 │  ├ <resource>.tf
 │  └ ...
 ├ staging-1    # 検証環境用のリソース定義
 │  └ ...
 └ ...

<resource>.tf は AWS リソースの種類ごとにリソース定義を分割して格納しているファイルです。例えば ECS に関連する ECR や ECS サービスのリソース定義は ecs.tf というファイルにまとめています。)

構成としては、環境ごとにディレクトリを分けてその中にリソース定義を各々書くというシンプルな形でした。

事業の立ち上げ期で本番環境しかないような状態でコードが書かれて、後から検証環境が必要になり本番環境用のコードを複製して... のようなあるあるパターンで生まれがちな構成だと思います。

環境が少ないうちは大して問題はありませんでしたが、組織が大きくなり検証環境の数も増えていく中で以下のような問題が出てきました。

  • ファイルやリソース定義の数が多いため、環境間の差分が分かりにくく比較が難しい
    • 各リソースのパラメータ
    • 特定の環境にしか存在しないリソース
  • リソース追加などの構成変更作業の手間が大きい
    • 同じリソース定義を環境の数分書く必要がある

これらの問題を放置しておくと環境が増えるごとにインフラの構成変更の作業コストが増えてしまうことが目に見えていたため、ディレクトリ構成の見直しを行うことにしました。

見直しの方針

今回の構成見直しにおける大方針は「環境間(本番環境、検証環境)でのリソース定義の重複をなくす(= DRY にする)」です。

各環境のディレクトリにある大半のコードが重複しているということが問題の根本原因のため、重複部分を共通化していく仕組みを取り入れました。

Modules vs Workspace

Terraform でのリソース定義の共通化の手段としては Modules と Workspace が候補にあがります。

Modules はリソース定義の一群を再利用可能なテンプレートとしてパッケージ化する仕組みです。

developer.hashicorp.com

Workspace では workspace という環境の分離単位を作成し、その workspace を切り替えることで同じリソース定義から複数の環境のリソースを作成することが可能です。

developer.hashicorp.com

Workspace は便利な一方で以下のような懸念点がありました。

  • 特定の環境にしか存在しないリソースがある場合に工夫が必要
    • 具体的には count を利用する方法 があるが、環境とリソースの対応を網羅的に把握することが難しい(各リソース定義のcountの有無を確認していく必要があるため)
  • workspace の切り替え忘れによる誤操作
    • Terraform Cloud を使う分には無視できるかもしれない
  • 環境の存在自体をコード化できない
    • コードからはどのような環境が存在しているか分からない

そのため、DIGGLE では Modules を採用して構成を行っています。

見直し後のディレクトリ構成

見直し後の Terraform のディレクトリ構成は以下の通りです。

aws
 ├ envs    # 環境ごとのリソース定義
 │  ├ production
 │  │  ├ main.tf
 │  │  ├ variables.tf
 │  │  ├ local.tf
 │  │  └ ...
 │  ├ staging-1
 │  └ ...
 └ modules    # 環境間で共通するリソース定義をmoduleとしてまとめる
    ├ base    # サービス間で共通利用されるリソース群
    │  ├ outputs.tf
    │  ├ variables.tf
    │  ├ <resource>.tf
    │  └ ...
    └ services  # サービス単位のリソース定義
      ├ service-A
      │  ├ outputs.tf
      │  ├ variables.tf
      │  ├ <resource>.tf
      │  └ ...
      ├ service-B
      └ ...

解説

従来からの差分として、ディレクトリを大きく envsmodules の二つに分割しています。

envs は環境単位でリソースをまとめる場所であり、環境差異となる部分は全て envs 下にまとまります。 そして、State ファイルも envs 下のディレクトリ単位で別れることになります。 すなわち Terraform の plan や apply を実行する単位での分割ともいえます。

modules ディレクトリには Terraform modules を利用して分割・再利用されるリソース定義を配置しています。

services 配下にはプロダクトを構成するサービスの単位でリソース定義を配置します。
この場合、サービスごとに同じような AWS リソースの定義が生まれ得ますが、この重複は許容することにしています。AWS リソースのまとまりごとにさらに module を作成して module から module を使うネスト構造にすることで回避することもできますが、必要以上に構成が複雑化し module 間の依存関係も考慮しなければならないため採用していません。

base 配下にはサービス間で共通して利用されるリソース群の定義を配置しています。
分かりやすいものだと VPC 等のネットワーク系リソースや Route53 のリソースが該当します。

modules 内のリソース定義は env 内の main.tf から参照する形で利用されます。

# main.tf

module "base" {
  source = "../../modules/base"

  vpc_cidr = "10.0.0.0"
  ...
}
# -> VPC を作成

module "service_a" {
  source = "../../modules/services/service-A"

  vpc_id = module.base.vpc_id
  ...
}
# -> base module で作成された VPC の ID を受け取り、サービス固有のリソースを作成

環境間での差異は main.tf で各 module に与えているパラメータに表れます。 そのため、各環境ディレクトリ配下にある main.tf を確認することで環境ごとの設定を容易に比較することができます。

また、AWS リソースの構成変更を行う場合は modules 内の変更だけで、variables の変更がない限りは envs 内の各環境側の変更は不要なため、従来構成よりも作業コストは削減できます。

補足: 構成変更適用時の運用フローについて

実際に構成変更を行う際には、まず検証環境に変更を適用し動作確認を行い、その後に本番環境へ変更を適用するという形をとると思います。

その場合の構成変更適用には下記の運用フローを想定しています。

  1. staging ブランチにおいて module 下のリソース定義を変更
  2. 検証環境(staging 環境)に terraform apply を実行して変更を適用
  3. staging 環境での動作確認が問題なければ、staging ブランチを main ブランチへマージ
  4. main ブランチで本番環境に terraform apply を実行して変更を適用

本番環境の構成は常に main ブランチの内容と一致することとした上で、検証環境の構成は柔軟に変更ができるという方針による運用となっています。

おわりに

今回はメンテナンス性向上のための Terraform ディレクトリ構成の改善について紹介しました。

「terraform ディレクトリ構成」でネット検索すると多くのプラクティスがヒットしますが、あらゆる組織にフィットするような決定版といえるものはないと考えています。 本記事で紹介したものも2023年時点の DIGGLE に最適だと考える構成であり絶対的な正解というわけではないため、数ある事例の一つとして参考にしていただけると幸いです。

We're hiring!

DIGGLE では共にプロダクトを開発してくれるエンジニアを大募集中です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers