DIGGLE開発者ブログ

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

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

React の Global State 管理で Jotai を採用した話

こんにちは。 DIGGLE エンジニアの ito です。 九月末になり、厳しい残暑に少しずつ終わりが見え始めた中いかがお過ごしでしょうか?

ここ数年は、残暑という名だけで本格的な暑さが続いているように感じています。 私はDIGGLEエンジニアという身分の他に自前の田んぼでお米を育てている兼業農家という身分もあるため、毎年稲刈りの時期までこの厳しい残暑が続いてしまうと困るなと思っています。 ですが、今年も無事残暑が落ち着いてから稲刈りを実施することができて安心しました。

毎年稲を刈る度に新しいライブラリが現れるフロントエンド界隈ですが、最近の DIGGLE のフロントエンドでは従来 ContextAPI で行なっていた React の Global State 管理を見直し、移行先として有力な Recoil と Jotai を比較した上で Jotai を導入することに決定しました。 導入決定の経緯と導入する際の工夫について今回はお話しさせていただきます。

React の Global State 管理に関して

React ではアプリケーションの規模が大きくなるとしばしば Global State を導入することになると思います。 導入理由は、コンポーネントを細分化していくにあたってstate/props のバケツリレーの階層が深くなり可読性が落ちていくためなど様々だと思われますが、 DIGGLE でも例に漏れず可読性向上を目的として Global State を導入して管理を行ってきました。

DIGGLE では従来 Global State として ContextAPI を利用しており、

react.dev

利用目的としては主に可読性向上だったため、Page コンポーネントなどの大元の親コンポーネントで Context を用意して子コンポーネントに伝播させていました。 (私の入社以前(2021年4月以前)には Redux を利用している時期もあったそうなのですが、ボイラープレートの管理が辛くメリットよりもデメリットが上回ったため ContextAPI に切り替えたとのことです)

上記のような使い方で可読性の向上をすることができたのですが、アプリケーションの成熟に伴って下記の問題が発生するようになりました。

  • 無駄な再レンダリングの発生
  • Context の肥大化
  • Provider が乱立

特にDIGGLEでは大きな表を描画する必要があるなど、パフォーマンス向上が必須となる機能特性上、「無駄な再レンダリングの発生」を抑止するために、今回手を入れることになりました。

問題解決に向けた Recoil の検討

「無駄な再レンダリングの発生」は ContextAPI の使い方の問題ではあるものの元々の目的である可読性を落とさずに解決する方法は難しく、setState + ContextAPI の構成の移行先としてよく挙げられる Meta から公開されている Recoil を検討することにしました。

recoiljs.org

Recoil は 2020年5月に Meta によってリリースされた React 向けの状態管理ライブラリで、atom という単位で Global State 管理を行います。

atom はデータを入れるための箱のようなもので atom を定義すると箱が用意され、各種 getter や setter によってデータを出し入れできるようになります。

recoiljs.org

また、レンダリングするか否かはコンポーネントごとに使われている atom の値に変更があるかで実施されます。

ContextAPI Recoil
再レンダリングの判定 Contextの値が変更されたら atomの値が変更されたら
再レンダリングの範囲 Context.Providerで囲われた範囲 atomの値が使われているコンポーネント

Recoilを利用した例

実際にRecoilを利用した場合で、どのように再描画が走るのかを確認したものが下記になります。

書いたコードは下記となっており、カウントをインクリメントするボタンを押してもuseRecoilValue を使っている GrandChildコンポーネントのみ再描画が走っていることがわかります。 また、ContextAPIと変わらず可読性高く記述ができることがわかります。

"use client"
import React from 'react'
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil'
import {hobbyAtomState} from "./atoms/HobbyAtom"

const GrandChild = () => {
  const count = useRecoilValue(hobbyAtomState)
  return (
    <div style={{backgroundColor: "blue", padding: "5rem"}}>
      <span>
        {count}
      </span>
    </div>
  )
}

const Child = () => {
  return (
    <div style={{backgroundColor: "yellow", padding: "5rem"}}>
      <GrandChild />
    </div>
  )
}

const Parent = () => {

  const setter = useSetRecoilState(hobbyAtomState)
  return (
    <>
      <div style={{backgroundColor: "green", padding: "5rem"}}>
        <Child />
        <button style={{marginTop: "1rem"}} onClick={()=>setter((count)=>count+1)}>
          count up
        </button>
      </div>
    </>
  )
}

export default function RenderTest(){
  return (
    <RecoilRoot>
      <div>テスト</div>
      <Parent />
    </RecoilRoot>
  );
};

一方で setState + ContextAPI を利用した場合では下記の図のようになり、カウントをインクリメントするボタンを押すとコンポーネント全体が再描画されていることがわかります。

上記検討で可読性を担保したまま再描画を抑えられることがわかったため Recoil での Global State 管理をおこなっていく方針で定めようと考えていました。 ですが、Recoil に懸念があることをある時メンバーから共有してもらったことで再度方針を考え直すことになります。

Recoil の懸念と Jotai の検討

Recoil で検討を進めていたところ DEV チームのメンバーから Recoil の開発継続性に対する不安があることを共有してもらいました。

実際のSlack上でのやり取り

github.com

上記の issue を確認するとRecoil の主要メンテナがレイオフされたり、Jotai に切り替える方がいることがわかります。 Recoil が Meta社によって開発されいてるため優先して検討をしていたのですが、上記状況を踏まえると Meta社による開発という優位性が怪しいものとなったため、Jotai と比較検討することにしました。

結論からお伝えすると Jotai を採用したのですが、採用理由は主に下記になります。

  • TypeScriptで開発されている
  • 開発が活発
  • callback 内などで atom を get する際に通常の atom の get と仕様が変わらない

TypeScriptで開発されている

Jotai は TypeSciprt で開発されているため、TypeScript で開発している DIGGLE のフロントエンドとも相性が良かったです。 型が細かく設定されており、型安全な状態で開発を進めることができました。

開発が活発

Recoil では先述の主要メンテナのレイオフであったり、major ver.の公開がされていないといった問題がありました。 Jotai は既に ver.2 が公開されており、月一以上でコンスタントに更新がされています。

callback 内などで atom を get する際に通常の atom の get と仕様が変わらない

Recoil にも Jotai にも useCallbackに似た hook が用意されています。

Recoil では useRecoilCallback であり、callback 内で atom の値を取得する際に snapshot と呼ばれるものを介して取得することになります。 最初に触った際にはここでも atom の時のように get で値を取得できれば嬉しいなと思っていました。

const logCartItems = useRecoilCallback(({snapshot}) => async () => {
    const numItemsInCart = await snapshot.getPromise(itemsInCart);
    console.log('Items in cart: ', numItemsInCart);
}, []);

recoiljs.org

一方 Jotai では useAtomCallback であり、atom の値を取得する際には atom の getter 同様に get で取得できます。

  const readCount = useAtomCallback(
    useCallback((get) => {
      const currCount = get(countAtom)
      setCount(currCount)
      return currCount
    }, [])
  )

jotai.org

似たような処理で同じような方法をとれることは可読性を上げ、実装する際には戸惑う可能性を減らすように感じました。 好みの問題ではあるのものの、上記の印象から私は Jotai の方が良さそうだと感じています。

Jotai への置き換えに関して

検討を通して Jotai を採用することに決めました。 すでに一部 Recoil で実装をしていた部分があったため、Recoil から Jotai への置き換えが発生しました。 ですが、 Recoil と Jotai には一部を除き仕様に大きな違いがなかったことや本格導入する前だったことから、置き換えの工数はそれほどかかりませんでした。

違いのあった点としては、リスト表示する複数の要素や大きなオブジェクト要素を atom で管理する方法です。

Recoil では atomFamily や selectorFamily といった collection の形でデータを保持するための Utils を用意しており、Recoil を導入した際には両方を使って処理を行なっていました。

import { atomFamily } from 'recoil';
import { Fact } from '@/models';

export const factsAtomState = atomFamily<Fact, number>({
  key: 'atoms.factsAtom',
  default: new Fact(),
});

recoiljs.org

一方 Jotai では atom の中に atom を入れるなど柔軟な表現が可能なため、 配列やオブジェクトを扱う際には、 atoms in atom パターンの利用が提案されていました。

import { atom, PrimitiveAtom } from 'jotai';
import { Fact } from '@/models';

type Facts = { [id: number]: PrimitiveAtom<Fact> };
export const factsAtom = atom<Facts>({});

jotai.org

そのため、置き換えの際に atomFamily や selectorFamily を Jotai 流の書き方に置き換えました。

また、Jotai では大きなオブジェクトを扱う際には focusAtom や splitAtom で分割するなどできます。 Recoil は atom の中に atom が入らないため、オブジェクトを細かく分割した atom を用意していたのですが、Jotai ではそれらをまとめて必要な単位で分割して切り出す形に変更しました。

import { atom } from 'recoil';

export const dateAtomState = atom<string>({
  key: 'components.features.Fact.dateAtom',
  default: '',
});

export const valueAtomState = atom<number>({
  key: 'components.features.Fact.valueAtom',
  default: 0,
});

Recoilの場合

import { atom } from 'jotai';
import { focusAtom } from 'jotai-optics';

type Fact = { date: string; value: number };
export const factAtom = atom<Fact>({ date: '', value: -1 });
export const dateAtom = focusAtom(factAtom, (optic) => optic.prop('date'));
export const numberAtom = focusAtom(factAtom, (optic) => optic.prop('value'));

Jotaiの場合

jotai.org

Jotai では手が届かない部分について

Jotai で気になった点として、 変数のスコープが複雑になりやすいという点があります。

Jotai では Atom を利用するコンポーネントを Provider で囲う必要があります。 ProviderはネストすることができるのですがそれぞれのProviderで独立してatomの値を管理することになり、useAtom などで何も指定せずに取得をすると取得コンポーネントがネストされている Provider の中で一番深いものから値をとってきます。つまり、Provider のネストによって atom のスコープが切られます。

Jotai の Provider による Atom のスコープについて

const RootComponent = () => (
  <Provider> // Provider A
    <Provider> // Provider B
      <ComponentA />
      <Provider> // Provider C
        <ComponentB />
      </Provider>
    </Provider>
  </Provider>
)

DIGGLEでは一覧ページから詳細ページへ遷移した場合や数値表現のプロパティを複数ページで使いまわしたいなど、ページを跨いだ変数のスコープが欲しくなる一方でパスパラメータやクエリパラメータの伝播などページごとに変数のスコープを切りたいものがありました。そのような場合でも一応狙ったProvider の値を取得する方法が Jotai には用意されており、 https://jotai.org/docs/guides/migrating-to-v2-api の Provider's scope prop の項目に方法が記述されています。

const MyContext = createContext()
const store = createStore()

  // Parent component
  <MyContext.Provider value={store}> // Provider A
    <Provider> // Provider B
      <ComponentA />
      <Provider> // Provider C
        <ComponentB />
      </Provider>
    </Provider>
  </MyContext.Provider>

  // ComponentB Component
  const store = useContext(MyContext)
  useAtom(..., { store })

この方法では ContextAPI を使う必要があり、useAtom 時に使用する store を指定することから多用はしづらいように感じました。

現状変数のスコープを切って扱いたいものはパスパラメータやクエリパラメータに限定されていることもあり、どうせ ContextAPI を使う必要があるならと state を使わないことを前提に一部は従来のまま ContextAPI を利用することにしました。

そのため DIGGLE としては今後 Jotai / ContextAPI の組み合わせで使っていく方針としました。

まとめ

DIGGLEでは Jotai / ContextAPI の組み合わせを利用することに決定しました。 Jotai はシンプルでわかりやすく可読性とパフォーマンスを両立しながら今後の開発を行なっていけそうです。 基本的には Jotai を用いて Global State 管理を行い、state を使わずに変数のスコープを切って再利用したいものに関しては ContextAPI を利用していこうと思います。

今回は現時点での DIGGLE の環境において最善と思われる Global State 管理を検討したものになっており、結論は時期/環境によって大きく左右されると思います。 そのため Jotai は素晴らしいライブラリとは思うものの今回の決定に囚われることなく、その時々の状況に応じて適宜方針を検討し直していきたいと考えています。

今回は DIGGLE において Global State 管理を Jotai に決めた経緯を紹介させていただきました。 本記事が皆様の検討の際の一助になれば幸いです。

We're hiring!

予実管理の明日を切り開く為に日々精進している私達と一緒に開発してくれるメンバーを大募集です。

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

herp.careers

Python、Rust上でのデータフレームライブラリで速さ対決してみた!

※ Apache Arrow, Apache Arrow DataFusionは、米国およびその他の国におけるApache Software Foundationの登録商標または商標です。これらのマークの使用は、Apache Software Foundationによる保証を意味するものではありません。

こんにちは。5月入社のソフトウェアエンジニアのhirataと言います。日本全国地獄のように暑い日々ですが、元気で過ごしていますでしょうか?

私は入ったばかりで覚えることが多く毎日アップアップしていますが、それでも計算式を実行するエンジンを作ったり、予実の数字を分析する機能を作ったりして、楽しく過ごしております。

我々のサービス「DIGGLE」で取り扱っている「お客様の経営に関わるデータ」はそこそこ規模の大きいデータになります。このデータを高速に集計・分析できることはお客様への価値向上に直結します。今回はそれに関わる技術調査の一環で、各データフレームライブラリを比較する機会があったので記事にしてみました。

データフレームライブラリとは

データ分析のためのライブラリでPythonのpandasが有名です。2次元のdataframe、1次元のseriesというデータを格納するためのオブジェクトを持ち、データの操作を行う際にはこれらのオブジェクトにデータを格納し、オブジェクトに対して計算内容を指示していきます。

dataframe, seriesに対して一括して計算処理を行うライブラリであり、実際の計算処理はC++言語等で作成されたライブラリ内で行われるため、Python等のスクリプト言語においても大量のデータを高速に処理することができ、コンパイル言語並の高速性とスクリプト言語であることによる柔軟性とを両立できます。

このように書くと、コンパイル言語でデータフレームライブラリを使う恩恵が無いように感じられるかもしれませんが、そんなことはありません。スクリプト言語程の差では無いですが、データのメモリ配置等により大量データの計算に最適化されているため普通にプログラムして計算するよりも高速に処理できます。データ処理以外も必要な場合はコンパイル言語を使用することも選択肢となるでしょう。

ということで、今回はコンパイル言語に匹敵する計算速度が本当に出るのかの検証を、PythonとRustの二つで検証しました。

前提条件

今回の検証では以下の条件で処理時間を検証していきます。

  • ファイルフォーマットはparquet形式。サンプルデータ数は100万行。
  • 処理内容は三要素でgroup byしてsumする。

今回エントリーするライブラリ

今回の検証に使用するライブラリと処理系です。社内の事情で今回はPython3ではなく、Python2で計測を行います。

言語 ライブラリ
Python(2.7.17) pandas(1.1.5) with pyarrow(6.0.1)
Python(2.7.17) polars(0.29.0) lazy
Rust(1.69.0) datafusion(25.0.0)
Rust(1.69.0) polars(0.29.0) lazy

それぞれについての簡単な解説と計算を行うためのソースを以下に載せていきます。

Python pandas について

https://github.com/pandas-dev/pandas

言うまでも無いとは思いますが、データ処理ではデファクトスタンダードとなっているライブラリです。 今回はapache arrowというインメモリのフォーマットを使うためのライブラリであるpyarrowも同時に使っています。 ソースは以下のような感じです。今回は数値計算ライブラリ(blas等)等の高速化のためのライブラリ導入は行っていません。試しに入れて見たところ今回の処理内容では実行速度が変わらなかったため、素のpandasライブラリを使っています。

import time
from pyarrow import fs
import pyarrow.dataset as ds

def main():
    start_time = time.perf_counter()
    
    dataset = ds.dataset("test.parquet", format="parquet")
    dataframe = dataset.to_table(columns=['date', "account_id", "unit_id", "value"]).to_pandas()
    series = dataframe.groupby(["date", "account_id", "unit_id"])["value"].sum()
    
    print('time:', time.perf_counter() - start_time)

if __name__ == '__main__':
    main()
Python polars について

https://github.com/pola-rs/polars

こちらもapache arrowを用いたrust製のデータフレームライブラリのpythonバインディングで、かなり高速との噂です。lazyな実行とeagerな実行の両方ができます。

lazyの方が速いので今回はlazyで評価しています。今回評価を行うためのソースは以下。

import time
import polars as pl

def main():
    start_time = time.perf_counter()
    df = pl.scan_parquet("test.parquet")
    series = df.groupby(["date", "account_id", "unit_id"]).agg(pl.col("value").sum()).collect()
    
    print('time:', time.perf_counter() - start_time, series)
    print(series)

if __name__ == '__main__':
    main()
Rust dafafusion について

https://github.com/apache/arrow-datafusion

apache arrowを用いたrustのデータフレームライブラリです。こちらもApache製の様なのでapache arrowのrust向け公式ライブラリっぽいです。

今回評価を行うためのソースは以下。SQLで書いてますが、SQLじゃなくても速度は変わらずでした。

use std::time;
use std::sync::Arc;
use datafusion::prelude::*;
use datafusion::arrow::record_batch::RecordBatch;

#[tokio::main]
async fn main() -> datafusion::error::Result<()> {
    let now = time::Instant::now();

    let ctx = SessionContext::new();

    let path = "test.parquet";
    
    // 集計用クエリの定義: SQL形式で記述可能
    let opts = ParquetReadOptions::default();
    ctx.register_parquet("facts", &path, opts).await?;
    let df = ctx.sql("SELECT date, account_id, unit_id, sum(value) FROM facts GROUP BY date, account_id, unit_id").await?;
    
    // collect() の実行で実際にクエリがexecuteされる
    let results: Vec<RecordBatch> = df.clone().collect().await?;
    
    let time = now.elapsed();
    println!("time: {:?}, result size: {}", time, df.count().await?);
    Ok(())
}
Rust polars について

https://github.com/pola-rs/polars

上記Python polarsで使用したpolarsのrustバインディングです。高速、省メモリに書けるのがrustのメリットということで、今回の最有力候補です。ソースは以下のとおり。

use polars::prelude::*;
use std::time;

#[tokio::main]
async fn main() {
    let now = time::Instant::now();
    let df = LazyFrame::scan_parquet("test.parquet", Default::default()).unwrap();
    let df = df.groupby(["date", "account_id", "unit_id"])
        .agg([col("value").sum()])
        .collect()
        .unwrap();
    let time = now.elapsed();
    println!("time: {:?}, result size: {}", time, df);
}

結果

以下計測結果です。

⁠実装 ⁠実行時間
Python pandas 0.54s
Python polars 0.76s
Rust datafusion 1.53s
Rust polars 0.25s

やはり期待通りRustでのpolarsが一番速いですが、Python版でもそれほど遅くなっていませんでした。またpandasの速さが予想以上でRust polarsとの差が2倍ほどに収まっています。この測定では処理が途中でライブラリ外に出る事が無いために、pythonオブジェクトに変換するためのオーバーヘッドがほとんど無いせいでしょう。Pythonで書く容易性を考慮すると、この程度の差であれば大抵の場合はpandasで十分ということになりそうです。

またRust datafusionが期待に反して遅いですが、ここはユーザ数が膨大で歴史もあるpandasの最適化具合には敵わなかったといったところでしょうか。「今回のユースケースでは」といったところもあるかと思いますので、皆さんが実際に使用する際には事前に想定されるユースケースでの計測を行う事をお勧めします。

まとめ

pandas素晴らしいですね。気軽に書けてこれだけ速度が出れば、デファクトスタンダードになる訳だなと思いました。

また、これは反省なのですが、実はこのブログを書くにあたりメモリの使用量を取ってなかった事に気がつきました。途中で処理速度にフォーカスした為、取らなくても良いかとその時は思っていました。が、改めて見直してみると、それがどの程度だったのかがとても気になります。メモリが気になりがちな大量データの処理なのでそこを曖昧にしておかずに、きちんとメモリ使用量も計測・記録しておいた方が今後の為に良いデータとなったでしょう。次回に向けての反省点です。

We're hiring!

予実管理の明日を切り開く為に日々精進している我々と一緒に開発してくれるメンバーを大募集です。

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

herp.careers