DIGGLE開発者ブログ

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

React Testing Libraryのrerenderを薄くラップすることで使い勝手を向上させた話

こんにちは。DIGGLE エンジニアの伊藤です。
ソフトウェアテスト Advent Calendar 2022の 20 日目の記事です。 Advent Calendar 初参加になります。

普段はフロントエンドを中心に業務を行なっています。
今回は DIGGLE で実施されているフロントエンドの単体テスト、 特にコンポーネントのテストで使っているフレームワークの使い勝手を向上させたお話をさせていただきます。

はじめに

DIGGLE ではフロントエンドで React を使っており、 テストフレームワークとして下記を利用しています。

Jest でモデルなどの React を介さない部分のテストを行い、React Testing Library を使って React コンポーネントのスナップショットテストを実施しています。React コンポーネントのテストで React Testing Library を使うか Enzyme を使うかといった違いはあるかもしれませんが、一般的によくある構成だと思います。

上記2つのフレームワークを用いることで DIGGLE でテストしたい項目を概ね満たすことができたのですが、 state を外部からコンポーネントに渡すテストを書こうとした際に上手く書けない問題がありました。

stateを外部からコンポーネントへ渡せない問題

例えば、React Testing Library を使って下記の様な Dropdown のコンポーネントをテストした際、

import { render } from '@testing-library/react';

---

it('Dropdownをクリックした時のテスト', async () => {
  const [value, setValue] = React.useState();
  render(<TestDropdown value={value} onClick={(e, v) => setValue(v)} option={...}/>);

// onClickした時のvalueの変化をテストしたい
});

としてuserEventでクリックイベントを発火させても value に値が反映されませんでした。
React Testing Library での関数は React の関数ではないので、React のフックを使おうとすると怒られるということで、 考えてみれば当然の話でした。
(Storybook では useState を使えたため、React Testing Library でも使えるのでは?と勘違いしたことが原因ですが、 おかげで Storybook と React Testing Library のスタンスの違いを感じることができました。)

上記の問題解消のためには、Jest + React Testing Library 構成の中で state を外部から props で渡すコンポーネントに関して「useState をモックしたい」という話になるのですが、

結論から先に書いてしまうと React Testing Library の rerender を活用することで問題を解決しました。

解決にあたって

解決にあたって候補になった方法は下記の2点でした

  1. rerender*1 を使って値を更新して擬似的に useState を表現する
  2. @testing-library/react-hooks というライブラリを使う

どちらの方法も甲乙つけがたいのですが、最終的には「1. rerender を使って値を更新して擬似的に useState を表現する」を採用することにしました。

「2. @testing-library/react-hooks というライブラリを使う」を採用しなかった理由は、

  • @testing-library/react-hooks はカスタムフックのテストに対する解決方法という色が強そう
    • DIGGLE ではカスタムフックの利用が活発でなく、導入の旨味が薄い
      • 展開のコストも考えると、急いで入れる必要はなさそう
  • React v18.0 対応を見てみると、 @testing-library/react-hooks の ver がサッと上がらない様子だった
    • 急いで入れて旨味を感じられず、管理の手間が増えるということになりかねない
  • 「1. rerender を使って値を更新して擬似的に useState を表現する」を実施する場合は薄くラップする必要があるとわかっていたため、「2. @testing-library/react-hooks というライブラリを使う」は必要になったタイミングでラッパーの中で切り替えれば良い

と考えたためです。

ただ、「1. rerender を使って値を更新して擬似的に useState を表現する」の方法にも問題はあり、 なにもせずに利用しようとするとほぼ同じ記述を 2 回することになってしまいます。

「rerender を使って値を更新して擬似的に useState を表現する」の問題点

愚直にテストを記述すると下記の形になります。( it 関数の中身だけを記載しています)

let rerender = _.noop;

const handleClick = jest.fn((value) => {
  rerender(<TestDropdown value={value} onClick={(e, v) => handleClick(v)} />);
});

const { rerender as TLRerender } = render(
  <TestDropdown value={value} onClick={(e, v) => handleClick(v)} />
);

rerender = TLRerender;

// onClickした時のvalueの変化をテスト

rerender を活用することにより、onClick が発火した際にvを与えながら再描画をhandleClick経由で行える様になります。

「useState をモックしたい」は上記で満たすことができたものの、TestDropdown をほぼ同じ形で2回書かないといけないことがわかると思います。

今回の様なシンプルな props ならまだ耐えられますが、 大量に props を渡す必要のあるコンポーネントに対するテストや onClick、onBlur など複数イベントに rerender の関数をセットしたい際に記述が煩雑になりそうなことがわかります。

問題点の解消 - rerender を薄くラップする

タイトルの通り、rerender を薄くラップすることで同じ記述を 2 回しなければならない問題を解決しました。

用意したラッパーは下記になります。

import { render as TLRender } from '@testing-library/react';
import _ from 'lodash';

export default class RerenderHelper {
  #rerender = _.noop;

  #render = _.noop;

  #firstRenderValue = null;

  constructor(render = _.noop, firstRenderValue = null) {
    this.#render = render; // render関数
    this.#firstRenderValue = firstRenderValue; // 初期描画時に利用されるvalueの値
  }

  // 再描画
  rerenderComponent = jest.fn((value) => {
    const r = this.#render(value, (v = value) => this.rerenderComponent(v));
    this.#rerender(r);
  });

  // 初期描画
  firstRender() {
    const { rerender } = TLRender(
      this.#render(this.#firstRenderValue, (v = this.#firstRenderValue) =>
        this.rerenderComponent(v)
      )
    );
    this.#rerender = rerender;
  }
}

やっていることはシンプルで、 同じ記述をしている部分を #render として持たせ、初期描画と再描画処理でそれぞれ呼び出しています。

また、#render を (value, setValue) => {} という形の関数で渡せる様にすることで、 使い勝手を useState に寄せています。

使い方は下記になります。

import Rerender from 'helpers/rerender';

---

it('Dropdownをクリックした時のテスト', async () => {
  const r = new Rerender((value, setValue) => {
    return (
      <TestDropdown value={value} onChange={(v) => setValue(v)} option={...}/>
    );
  });
  r.firstRender();

  // onClickした時のvalueの変化をテスト
});

上記ラッパーでできることは限られているものの、カスタムフックを使っていない環境であれば問題ないと思います。

まとめ

今回は DIGGLE のフロントエンドテストの環境を、フレームワークの一機能をラップすることで向上させた事例について紹介しました。

フロントエンドに限らずテストではさまざまなモックがされるかと思います。
モックする箇所や基準は様々だと思いますが、必要な部分に絞って慎重にモックを適用していく一例として今回の事例が参考になれば幸いです。

モックの濫用を防ぐ意味でも、カスタムフックの活用が活発になるまでのつなぎとして役割を果たしてくれると思います。

We're hiring!

今あるものを更により良くするための方法を、我々と一緒に模索してくれる開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net

*1:rerender自体の説明は公式にあるためここでは割愛させていただきます https://testing-library.com/docs/react-testing-library/api/#rerender

RailsのJOIN方法の違いでソートしたときに意図した結果を取得できてなかった話

こんにちは。DIGGLEのエンジニアのhondaです。
Ruby on Rails Advent Calendar 2022の12日目の記事です。
Advent Calendar初参加です。

はじめに

Railsで開発している方にはN+1問題というのはおなじみだと思います。(説明は割愛します)
そのためincludesやeager_load, joinsなどのN+1問題を起こさないためのメソッドに関する理解は必須だと思います。
自分もそれぞれの意味を把握して使い分ける程度にはわかっているつもりだったのですが、その理解が甘かったのでソートした時に意図した結果を取得できていませんでした。 今回はそのときの失敗談と解決した方法をお話します。

状況設定

今回は説明のために簡略化した↓のテーブル定義を使います。 データベースはpostgresqlを使用しています。

ER

要件はitemsをユーザーにリスト形式で表示します。
このときユーザーは任意のitem_tag_fieldsを指定してitem_tagsのvalueでソートした結果を取得できます。
なお、item_tagsのtag_field_idとitem_idに対してunique_indexを貼っています。 ユーザーに見せるインターフェースは以下のようなイメージになります。

item list

ID name tag_field_A(ソート可能) tag_field_B(ソート可能)
1 item_name_A value_A_1 value_B_3
2 item_name_B value_A_2 value_B_2
3 item_name_C value_A_3 value_B_1

以下では↑のようなレコードがDBに保存されていることを想定します。

どのような失敗をしたのか

例えばtag_field_id = 1でソートしたいとしたとき以下のような処理を書いたとします。 簡略化のためにtag_field_idのパラメータ化やsanitizeなどの処理は省略します。

items = Item.eager_load(item_tags: :item_tag_field).order(Arel.sql(<<~SQL))
  CASE item_tags.item_tag_field_id
  WHEN 1 THEN item_tags.value
  ELSE NULL
  END
SQL

> items.first.name # => item_name_A
> items.last.name # => item_name_C
> items.count # => 3

この結果だけ見ると上手くいっているように見えなくもないですが、実は問題をはらんでいます。
eager_loadにしているのはorder by句に指定しているitem_tagsテーブルとのjoinを確実に行うことを意図して書いていました。
変更前はincludesで書かれていたのもあってeager_loadにしておけば変なことにはならないだろうという漠然とした考えもありました。

# 変更前のコード
Item.includes(item_tags: :item_tag_field).order(:id)

一旦は問題がないと思ってしまった...

実はこのコードを書いた段階では自分はitems.countの部分は正しく動かないだろうと思っていました。
理由は発行されるSQLにありました。 実際にSQLを見てもらうとわかりやすいと思います。
items.to_sqlでSQLの文字列を出力して整形してカラム名をわかりやすく変更し、実行した結果が↓のものになります。

SELECT "items"."id" AS item_id,
       "items"."name" AS item_name,
       "item_tags"."id" AS item_tag_id,
       "item_tags"."item_tag_field_id" AS item_tag_field_id,
       "item_tags"."value" AS value,
       "item_tag_fields"."name" AS item_tag_field_name
FROM "items"
LEFT OUTER JOIN "item_tags" ON "item_tags"."item_id" = "items"."id"
LEFT OUTER JOIN "item_tag_fields" ON "item_tag_fields"."id" = "item_tags"."item_tag_field_id"
ORDER BY CASE item_tags.item_tag_field_id
             WHEN 1 THEN item_tags.value
             ELSE NULL
         END
;
 item_id |  item_name  | item_tag_id | item_tag_field_id |   value   | item_tag_field_name 
---------+-------------+-------------+-------------------+-----------+---------------------
       1 | item_name_A |           1 |                 1 | value_A_1 | tag_field_A
       2 | item_name_B |           3 |                 1 | value_A_2 | tag_field_A
       3 | item_name_C |           5 |                 1 | value_A_3 | tag_field_A
       2 | item_name_B |           4 |                 2 | value_B_2 | tag_field_B
       3 | item_name_C |           6 |                 2 | value_B_1 | tag_field_B
       1 | item_name_A |           2 |                 2 | value_B_3 | tag_field_B
(6 rows)

ご覧の通り6件の結果が出力されています。 items : item_tags は 1:Nの関係にあるのでjoinするときに取得件数に影響がでます。 なので、自分としてはitems.count == 6になるのではないかと思っていました。 しかし、実際には↓のような処理がおこなわれました。

# countした時に別のsqlが発行される
> items.count
SELECT COUNT(DISTINCT "items"."id") FROM "items" LEFT OUTER JOIN "item_tags" ON "item_tags"."item_id" = "items"."id" LEFT OUTER JOIN "item_tag_fields" ON "item_tag_fields"."id" = "item_tags"."item_tag_field_id"
=> 3

joinしたテーブルが1:N関係にあるときはCOUNT(DISTINCT items.id)しているので重複レコード分はカウントしないようになっています。
これを見て「ああ、Railsがよしなにやってくれるんだな」と思い手元でテストした感じも動いてそうだったのでこの書き方で問題ないと思っていました。

正しく値を取得できないときがある

しかし、このコードはページング処理を行うときに問題が発生します。
kaminariを使って2件づつ取得する処理をおこなうとします

# 1ページ目は問題なさそうに見える
page1 = items.page(1).per(2)
page1.pluck(:id) # => [1, 2]
page1.current_page # =>1
page1.total_pages # => 2
page1.total_count # => 3

# 2ページ目にレコードが2件ある!?
page2 = items.page(2).per(2)
page2.pluck(:id) # => [2, 3]
 
# 3ページ目!?
page3 = items.page(3).per(2)
page3.pluck(:id) # => [1, 3]

ご覧のとおりページネーション(ORDER BYとLIMT OFFSET)を使うと意図していないレコードが取れてしまいます。 自分でテストした段階では入れていたデータがよくなかったのか、この問題に気づけませんでした...

解決策

このバグは開発終盤の方に見つかったのもあってか、お恥ずかしい話、自分はいい修正方法が思いついていませんでした。
しかし、弊社のハイパーエンジニアことokazakiさんにこのことを相談したところ、 order byするときは必要なレコードに絞ってjoinすれば問題ないという至極真っ当なアドバイスをもらいました。(なぜ気づかなかったのか...)

また、関連テーブル先のデータはeager_loadで取得するのではなくpreloadで取得するようにしてORDER BYを含むSQLを発行する際に余計なテーブルとjoinしないようにしました。
例の如く簡略化のためにtag_field_idのパラメータ化やsanitizeなどの処理は省略します。

join_clause = <<~SQL
  LEFT OUTER JOIN item_tags ON items.id = item_tags.item_id
  AND item_tags.item_tag_field_id = 1
SQL

items = Item.joins(join_clause).preload(item_tags: :item_tag_field).order('item_tags.value')

items.to_sql 
=begin
SELECT "items".*
FROM "items"
LEFT OUTER JOIN item_tags ON items.id = item_tags.item_id
AND item_tags.item_tag_field_id = 1
ORDER BY item_tags.value
=end

# ページネーションも問題なし

page1 = items.page(1).per(2)
page1.pluck(:id) # => [1, 2]

page2 = items.page(2).per(2)
page2.pluck(:id) # => [3]

page3 = items.page(3).per(2)
page3.pluck(:id) # => []

これで上手くソートもページネーションもできました。よかったよかった。
普段ActiveRecord::Relationを使っていると意外と頑張ってやってくれるので大変重宝しているのですが、 1:N関係にあるレコードでページネーションするときは注意が必要というお話でした。

We're hiring!

今あるものを更により良くするための方法を、我々と一緒に模索してくれる開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net

ふりかえり(レトロスペクティブ)を活用してチームの自己組織化を促す

UnsplashRavi Palweが撮影した写真

フロントエンドエンジニア兼デザイナーの大澤です。
今回は主にふりかえりによってDIGGLEの開発チームがいかに自己組織化を達成したか、その肝である「ふりかえり」をどのように実施したかについてお伝えします。

なぜ自己組織化を求めるのか

自己組織化したチームには以下の利点があります。

  • 少ないルール
  • 少ない管理コスト
  • 問題が放置されづらい(誰かが拾って対応できる人に渡される)
  • 互いがサポートする中で生まれる安心感
  • 良い意味で交代可能(穴は誰かが埋めてくれるという信頼がある) -> 有給取りやすい
  • そもそもスクラムに必要

DIGGLEではスクラムを採用したので自己組織化は必須でした。
ただ仮にスクラムを採用しなかったとしても上記の利点は魅力的です。
個人的には、ルールを守るために注意を割かれるのが嫌い(そんなことより仕事自体に集中したい)なので、少ないルールで動けるチームであると嬉しいと考えていました。

実際に得たこと

ふりかえりを起点とした改善行動によってDIGGLEの開発チームは、おおよそ自己組織的になったと思います。
上記のメリットに加えて、以下のことも達成できました。

  1. 安定したベロシティ&バーンアップの上昇
  2. 全員がスクラムマスターになれる(輪番制)
  3. 上記をフルリモート環境で運用できている

これらは継続的なふりかえりによる問題発見と改善によってもたらされたと考えています。

過去の失敗経験と学び

狭い経験内の話ではありますが、DIGGLEほど自己組織化に成功したチームはありませんでした。
過去に自己組織化に失敗した事例を思い返すと、以下の理由が思い当たります。

  1. スクラムやアジャイルのプラクティスを綺麗に回すことだけに気を取られる(形式重視)
  2. 既にあるチームや組織の慣習から離れられない
  3. プラクティスの適用を急いでしまう

特に3番目は、1と2が合わさると発生しやすくなります。
素早くプラクティスを適用して、悪い/非生産的(と思っている)文化からの脱却を図ろうとします。しかし、急いで適用するためプラクティスは浸透しません。メンバーは怒られないために仕方なくやっている状況となり、主体性に欠けた受け身のチームが出来上がります。

自分なりの回答: 自己組織化は時間がかかる

自己組織化はチームが主体的に改善を繰り返していく中で達成できます。改善のほとんどは小さなものですが、この積み重ねが大きな変化をもたらします。チームが自分達のものだと強く認識でき、結果として自己組織化が促されます。

自己組織化は一朝一夕で達成できるものではなく、継続的に取り組んだからこそ得られる状態です。
そして、チームの改善行動を促す仕組みこそが「ふりかえり(レトロスペクティブ)」なのです。

シンプルに始める

DIGGLEでは以下のことを重視しました。

  1. 必要なスクラムプラクティスから少しずつ取り入れる
  2. ふりかえりでの問題発見と改善行動の繰り返しの中で「少しずつ」改善する

スクラムを取り入れた当時、必要最小限と考えたプラクティスは以下のものです。

  • スプリント(タイムボックスの役割)
  • プランニング(タスク見積もり)
  • ふりかえり(問題発見と改善)

初期はデイリースクラムもレビューもリファインメントもありませんでした。
今でこそこれらのイベントも実施していますが、加わったのはふりかえりの中で問題が見えてきた後になります。
問題が顕在化してから適用することで「なぜやるのか」が明確になります。

問題を率直に共有することの重要性

「部屋の中の象」という言葉があります。
誰の目にも明らかな問題が存在するのに、誰もそのことに触れない状況のことを指します。もし部屋に象がいたら、それこそが「本物の問題」です。ふりかえりで共有される問題が「本物の問題」でなければ、ふりかえりの効果が極めて限定的になります。

以下のような状況では率直な共有がされません。

  • 問題報告者が面倒ごとを押し付けられる
  • 問題を共有したけど無視される
  • 切断処理(「君の問題だよね?」)
  • 心理的安全性を欠く

上記のような状況を引き起こさないためにファシリテーター(スクラムの場合はスクラムマスター)が率先して状況を切り開く必要があります。
と言っても自分から問題に突っ込むというより、メンバーに率直さと積極性を促すことが重要です。呼び水となる問いかけをしましょう。ふりかえりの場ではチーム全員が話して良いという雰囲気を作り出すことで、本物の問題を引っ張り出すきっかけになります。

ふりかえり手法について

DIGGLEではKPTを用いていますが、問題を共有して改善行動に繋げることができれば何でも良いと思っています。
大事なことは以下の通りです。

  • 定期的に開催する
  • 対象を限定する(ex: 直前のスプリント)
  • 思いつく限り書き出す(書き出すハードルは低めにする)
  • 具体的な改善行動につなげる(次のスプリントで実行できると望ましい)

具体的な改善行動まで繋げられると理想です。とはいえ、最も重要なことはチームメンバーが感じている問題を共有することにあります。問題が共有されていれば、後はどうとでもなります。

反対に最も怖いのが「問題が見えない」ことです。チームメンバーが萎縮したり、無力感を感じていると必要な共有がされません。ふりかえりには心理的安全性が必要です。ファシリテーターはこれを維持する努力をしましょう。

「ふりかえりではどんな問題も話し合える」と感じてもらえるのが理想です。

結果の出ないふりかえりになる覚悟を持つ

ここまで色々書いてきましたが、いつだってうまくいくわけではありません。
例えば、以下のような状況になって十分な成果を出せないときもあります。

  • 議論が長引いて、残りの問題について話し合う時間が不足する。結果、適当に終わらせてしまう。
  • 疲労などの影響で注意が逸れて必要な議論が不足する(面倒になってしまう)
  • 自信を失って話し合いを打ち切ってしまう

毎度完璧で効果的なふりかえりができるなんてことはありません。うまくいかない日は必ずあります。
そんな日があったとしても、改善プロセスを繰り返すことこそが重要なのです。
「次こそはもっと良くしよう」「小さくても良いから次の改善に繋げよう」という意識を持ち続ける限り、チームを成長させることができます。

諦めずに続けていきましょう。

まとめ

DIGGLEではふりかえりを改善プロセスの中心に据えることで自己組織化を達成できました。
この結果としてスクラムの運用とチームの独立性を保てていると思います。

もちろん今後がどうなるかは分かりません。
しかし、ふりかえりを中心にした改善プロセスが回り続けることは変わらないと思っています。

We're hiring!

DIGGLEでは主体性を発揮してチームで最高のプロダクトを作りたいエンジニアやデザイナーを募集しております!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

herp.careers

検証用スクリプトの実行環境として AWS Cloud9 を採用した話

こんにちは。DIGGLE エンジニアの miyakawa です。
普段はバックエンドとインフラを中心に開発しています。

今回は、前回の記事で触れたレポート機能の数値検証の「実行環境」についてお話したいと思います。

レポート機能の数値検証の内容については前回の記事をご覧ください。 diggle.engineer

数値検証の実行環境

まず結論として、現在は数値検証の実行環境として AWS の Cloud9 というサービスを利用しています。

Cloud9 はクラウドベースの統合開発環境(IDE)を提供してくれるサービスです。

docs.aws.amazon.com

IDE なので開発環境としての用途が一般的かと思いますが、弊チームでは検証用スクリプトの共有実行環境として活用しています。

次に Cloud9 を採用した経緯についてお話しします。

実行環境選定の経緯

ローカル環境での課題

当初、数値検証の実行は開発メンバーのローカル環境 (PC)上で行っていました。

数値検証には本番環境のデータベースのデータを複製したデータベース(以降、数値検証用 DB)を用いますが、セキュリティ上ネットワーク外部からの直接アクセスはできないようになっています。

そこで、ローカル環境から数値検証用 DB へアクセスするために、EC2 インスタンスによる踏み台サーバを用意し AWS Systems Manager の Session Manager を使ってセキュアにログイン、数値検証用DBまでポートフォワード接続できる環境を構築していました。

しかしながら、このローカル環境で数値検証を行う方法では以下の問題がありました。

  • 数値検証の実行完了まで数時間かかり、その間ローカル環境のリポジトリのブランチは固定かつ PC リソースも消費されるため開発作業に支障が発生する
  • 40〜60分ほどで Session Manager の接続が切れて数値検証の実行が途中で止まってしまう*1

二つ目の問題については、数値検証スクリプトに途中の段階からリトライするオプションがあるため、実行が止まるたび都度リトライすることで対処していましたがかなりの手間となっていました。

ローカル環境以外の方法の模索

ローカル環境を使う運用は難しいと判断し、それ以外の方法を検討し始めました。

ここで一度数値検証の実行環境の要件をまとめます。

  • Rake タスクを実行できる or Docker コンテナを実行できる
  • リポジトリの任意のブランチで実行することができる
  • AWS のプライベートネットワークにある数値検証用 DB にアクセスできる

これを踏まえて検討した結果、以下のような案が出ました。

  • EC2 インスタンスにローカル環境と同様の環境(git + Docker)を用意して数値検証を実行する
  • 上記に加えてジョブ管理ツール(Rundeck など)を導入して Web ベースで作業できるようにする
  • Github Actions のワークフローとして実行する*2
    • 数値検証用 DB アクセスのために EC2 インスタンスで self-hosted runners を構築する

いずれも AWS 上に EC2 インスタンスを作成するという点は同じで費用面でのコストに差はありませんでした。
後は工数との兼ね合いかと考えていたところで、AWS のソリューションアーキテクトの方と相談の機会があり本件についても相談してみました。

すると
「Cloud9 が適しているのはないか」
とのご助言が...!

それまで Cloud9 は使ったことがあってもあくまで開発環境用というイメージしかなかったため目から鱗でした。

早速 Cloud9 による環境を検証したところ、次に述べる Cloud9 の利点から、先に検討していた各案よりも優れていると判断し最終的に採用へ至りました。

Cloud9 の利点

環境作成が容易

Cloud9 の EC2 環境では EC2インスタンスをベースにクラウド IDE 環境を構築できます。
AWS コンソールからネットワーク(VPC)や接続方法等の最低限の情報を設定するだけで、すぐに開発を始められる EC2 インスタンスが作成されます。
(Cloud9 の環境作成方法の詳細については こちら を参照ください)

IDE の機能を除いたとしても、一から EC2 インスタンスをセットアップする場合と比べて多くの利点があり、セットアップの工数を減らすことができます。
例えばですが、

  • (接続方法として選択した場合)Session Manager の設定が自動で行われる
  • git、docker といった開発に必要なツール群がプレインストールされている

などが挙げられます。

IAM ユーザによる共有

Cloud9 の環境は作成時点では作成者のみが利用できる状態です。 そこに IAM ユーザを招待する形で他の開発者に環境を共有することができます。

仮に素の EC2 インスタンスへの Session Manager によるアクセスを IAM ユーザごとに可否設定する場合は各 IAM ポリシーを設定する必要が出てきますが、Cloud9 であればクラウド IDE 上で操作するだけなので非常に管理が楽になります。

また CloudTrail から証跡(いつ、どの IAM ユーザが Cloud9 を利用したか)も確認できるため、万が一の時のためにも安心です。

費用削減の機能がある

開発・検証用の環境は常に利用するわけではないため、夜間停止などの仕組みを入れたくなると思います。

EC2 インスタンスであれば AWS Systems Manager Automation を利用するなど一工夫が必要になります。

一方で Cloud9 にはセッションがなくなった後に一定時間が経過後 EC2 インスタンスを自動で停止する機能があります(停止するまでの時間は選択可能です)。
これにより、Cloud9 を使用する時のみ EC2 インスタンスが起動している状態となるので、費用の無駄を防ぐことができます。

Cloud9 環境セットアップ時の注意点

Docker Compose はインストールされていない

前節で docker がプリインストールされていると言いましたが Docker Compose はインストールされていません。

Docker Compose が必要な場合は、Docker の公式ドキュメント を参考にインストールしましょう。

ディスクサイズの拡張

Cloud9 のデフォルトのディスクサイズ*3は 10GB でその内大半はシステムで利用されており、環境作成時点でユーザが利用可能な領域は 2GB 程度です。
これでは多少大きめの Docker イメージをいくつかプルするだけであっという間にストレージが枯渇してしまいます。

そのため、環境を作成した時点でディスクサイズを拡張しておくことをおすすめします。

docs.aws.amazon.com

まとめ

今回は数値検証の実行環境として Cloud9 を採用するまでの経緯について紹介しました。

Cloud9 は他にもいろいろと活用できそうに思っているので、今後も新しいユースケースが出てきたら紹介していければと思います。

We're hiring!

DIGGLEでは最高のプロダクトを一緒に作ってくれるエンジニアを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net

*1:主にポートフォワードの接続が切れるという状況。調査したところ Session Manager のセッションタイムアウト(https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-preferences-timeout.html)が原因ではないことは分かりましたが、根本の原因は分かっていません

*2:弊チームでは CI として Github Actions を利用しています

*3:Cloud9 の環境は EC2 インスタンスで構築されているので、ディスクサイズ = EBSのボリュームサイズを示します

レポート機能の計算結果が正しいことをどう保証するのか考えた話

こんにちは。DIGGLEエンジニアのhondaです。
開発メンバーの中では主にバックエンドを担当することが多いです。

今回はDIGGLEの根幹を支えるレポート機能の数値検証についてお話したいと思います。

DIGGLEのレポート機能

DIGGLEのレポート機能

DIGGLEでは取り込まれたデータを集計し予実管理に役立つレポートを自動で生成する機能があります。 データは単月、累計、部門、科目など様々な角度からリアルタイムに分析が可能になっています。
もちろん今後もDIGGLEの根幹の機能として進化を続けていく予定です。

レポート機能の検証に関する課題

そんな素晴らしいDIGGLEのレポート機能ですが、機能の検証に課題がありました。 自由度の高いレポートを作成できるがゆえに 条件の組み合わせはほぼ無限に存在し、レポートの数値の正しさを保証することが難しい状況でした。 もちろんモデルやコントローラレベルでのテストも存在していて、リリースのたびにきちんQAもおこなうのですが、
「本当にちゃんと計算できているだろうか」
「あれだけテストしたから大丈夫なはずだけど」
といった不安な気持ちが常につきまとっていました。

検証の方法を考える

検証作業の課題を洗い出すと以下のような課題が浮き彫りになりました。

  • 手動でテストをおこなうには時間がかかる
  • 条件の組み合わせが多岐に渡るため網羅的にテストすることが大変
  • 機能追加は随時おこなわれるのでテスト項目の管理に継続的にコストがかかる
  • モデルやコントローラーレベルのテストはあるが、ユーザーが直接見る数値に対する検証が薄い

これらの問題を解決するために検討したのが数値検証の自動化でした。
DIGGLEにはレポートの数値をCSVで出力する機能があります。この機能を使って出力されたCSVファイルに対する検証をおこなえば ユーザーが直接見る数値レベルの検証を自動でおこなえるので、工数削減が期待できそうです。
しかし、どうなっていれば数値が正しいと言えるのかということを考えるとなかなか難しいです。
用意したテストデータに対して期待する値が出力されることをテストするということも考えたのですが、 そのデータ自体の正しさや、データと期待する値のメンテナンスの工数などを考えると現実的ではないと判断しました。

入力データと正しい出力結果を簡単に準備する方法を考えたときに思いついたのが本番のデータを使うことでした。 本番のデータであれば多様なデータと多様なレポート形式、そして我々開発チームの血のにじむような努力によって支えられた確からしい出力結果が存在するのです。 もちろん、本番の出力結果が間違っていればテスト結果も間違えることになるのですが、完璧に正しいと言える出力結果が存在しない以上 本番の出力結果はかなり確からしい出力結果であるためメンテナンスのコストも考慮したときに妥当な選択肢と判断しました。

具体的な検証方法

以下のような手順で検証をおこないます。

  1. 数値検証用環境にリリース前のデータベースと同じデータの入ったデータベースを作成する
  2. リリース済みのブランチでこのデータベースに対して全てのレポートでCSV出力をおこなう
  3. リリース予定のブランチに切り替えてmigrationを走らせる
  4. 再度全てのレポートをCSV出力する
  5. 2と4の結果を比較する

これで既存のレポート機能に関しては様々な条件でデグレードしていないことが検証できます。 新規機能については残念ながら工数を抑えてデータの準備をおこなう方法が思いつきませんでした。(というかそんな方法はないんじゃないか?) しかし、既存分だけのテストを自動化しただけでもかなりの工数を削減できたはずなので今回はこれで良しとしました。

また、この検証をおこなうためには本番相当のデータベースの準備が必要になるのですが、 数値検証用環境は、安全のため外部からはアクセスできないようになっています。 今回は数値検証環境の説明については省略します。次回の記事で紹介予定なのでご期待ください。

データベースに保存された全てのレポートに対してCSV出力をおこなう

ようやくコードの話になります💦
やること自体は単純です。DIGGLEではレポートの条件がjson形式でデータベースに保存されています。 そのjsonをコントローラに対してpostするとcsvが返ってくるのでそれを全てのレポートに対しておこないファイルに書き出すということをおこないます。 数値検証はリリースのたびに実行する性格上Rakeタスクとして実装しました。
但し、通常のRakeタスクでは行わない以下のことをあえてやる必要があります。

  • Rakeタスクからコントローラに直接jsonをpostする
  • Deviseによる認証をスキップする

これだけ書くとやばいことをやってる感がありますがライブラリで用意されている機能を使うだけなのでご安心ください。
但し、テスト環境前提の機能なので本番環境等で使うことはおやめください

言葉で説明するよりコードを見たほうが早いと思うので実際のコードを元に簡略化したコードを載せます。 省略して書きましたが大体以下のような処理を行っています。

# wardenのテストモードを使う
# これでテスト用のヘルパーが使えるようになる
def use_warden_test_mode!
  Warden.test_mode!
  # CSRF保護設定を外す
  ApplicationController.allow_forgery_protection = false
  yield
  # rakeのプロセスに閉じているので変更しても影響は無いはずだが念の為元に戻す
  ApplicationController.allow_forgery_protection = true
  Warden.test_reset!
end

# リクエストするユーザーを偽装する
def next_request_user(user)
  Warden.on_next_request do |proxy|
    # Deviseを使っているモデルのインスタンスからスコープを取得する
    scope = Devise::Mapping.find_scope!(user)
    opts = { event: :authentication, scope: scope }
    # 認証されたユーザーを設定する
    proxy.set_user(user, opts)
  end
end

use_warden_test_mode! do
  companies.each do |company|
    # スキーマを切り替える
    company.exec_in_tenant do
      # 全てのレポートを閲覧できるユーザーでリクエストを投げる
      next_request_user(User.find_by(role: :owner))
      session = ActionDispatch::Integration::Session.new(Rails.application)
      status = session.post(url, params: parameter_hash, as: :json)
      # ファイルに書き出す
      write! session.response.body
    end
  end
end

それでは普段やらないであろう箇所について、簡単にどうやっているのかを補足します。

Rakeからコントローラを直接叩くにはActionDispatch::Integration::Sessionを使います。

api.rubyonrails.org

これでsessionを偽装し、コントローラに直接postすることができます。

session = ActionDispatch::Integration::Session.new(Rails.application)
status = session.post(url, params: parameter_hash, as: :json)
# ファイルに書き出す
write! session.response.body

Deviseの認証をスキップする方法はRakeの認証ミドルウェアであるWardenのtest_mode!を使います。

www.rubydoc.info

名前の通りminitestやrspecなどのテスト環境で使うメソッドですが、今回はRakeタスクで使います。 このメソッドを使うことでWarden.on_next_requestを始めとした様々なテスト用のヘルパーメソッドを使用することができます。 このon_next_requestを使用してリクエストを投げるユーザーを設定することで認証をスキップできます。 scopeやDevise::Mappingが何かという話はDeviseの仕様に踏み込むので今回は説明を割愛します。

# リクエストするユーザーを偽装する
def next_request_user(user)
  Warden.on_next_request do |proxy|
    # Deviseを使っているモデルのインスタンスからスコープを取得する
    scope = Devise::Mapping.find_scope!(user)
    opts = { event: :authentication, scope: scope }
    # 認証されたユーザーを設定する
    proxy.set_user(user, opts)
  end
end

これでRakeタスクを実行すれば、レポートをCSVに出力することができるようになりました🎉
この数値検証の自動化はすぐに威力を発揮し、バグの検出に役立ちました。

次回予告

今回はDIGGLEに数値検証を導入した経緯とその詳細をコードを交えてご紹介しました。 この数値検証を実行している環境がどうなっているかについては次回弊社の優秀なインフラエンジニアがご紹介いたしますのでご期待ください。

We're hiring!

今あるものを更により良くするための方法を、我々と一緒に模索してくれる開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net

Rails+ApartmentにPostgreSQLのプロシージャを導入して多軸分析の集計速度を向上させた話 その5 ~apartmentへのPR編~

こんにちは。DIGGLEエンジニアのzakkyです。

遂に今回で最終回となります。レポート(多軸分析)の集計処理部分のパフォーマンス向上施策の第5弾です。

前回までの記事の紹介

diggle.engineer diggle.engineer diggle.engineer diggle.engineer

今回のお題

今回はapartmentに対してPRを出すに至ったあれやこれやをご紹介します。

書かないこと

以下の内容は含みませんので予めご了承ください

  • Rails、Apartmentの導入などの基礎的な部分
  • apartmentの説明

どれくらい速くなったのか

前回も記載しましたが、以下が今回改善した集計処理部分のLatency推移となります。 リリース後には低下していることが見て取れるかと思います。

大体3倍速程度には改善することができました。

赤線の辺りで今回の改善版のリリースを行っています

publicスキーマに対する参照があるプロシージャが動かない

ここでまた別の問題が発生しました。

以下のようなプロシージャがあるとします。(サンプルのSQLなので「User.allで取れるじゃん!」とは言わない約束です)

BEBIN
RETURN QUERY SELECT * FROM public.users;
END;

※前後のプロシージャ関連の定型文は省略しています

new_tenantテナントを作って、こちらのプロシージャを見ると…

BEBIN
RETURN QUERY SELECT * FROM new_tenant.users;
END;

と、publicであるべき箇所が、new_tenantとなってしまう問題が発生しました。

今回も、最終的な変更内容については結論に記載していますので、結果だけ知りたい方は最下部を参照ください。

原因を探る

またライブラリ(今度はApartment)を読む時間です。 偶然ですが、以前に余談として書いた部分にヒントがありました。

テナント作成時の流れを追う

重要な部分だけ抜き出してみました。 import_database_schemaはテナント新規作成時に呼び出され、その中のcopy_schema_migrationsでは、pg_dumpをした上でpatch_search_pathというfunctionを呼び出しているようです。

      def import_database_schema
        preserving_search_path do
          clone_pg_schema
          copy_schema_migrations
        end
      end

      def copy_schema_migrations
        pg_migrations_data = patch_search_path(pg_dump_schema_migrations_data)
        Apartment.connection.execute(pg_migrations_data)
      end

      def pg_dump_schema_migrations_data
        with_pg_env { `pg_dump -a --inserts -t #{default_tenant}.schema_migrations -t #{default_tenant}.ar_internal_metadata #{dbname}` }
      end

      def patch_search_path(sql)
        search_path = "SET search_path = \"#{current}\", #{default_tenant};"

        swap_schema_qualifier(sql)
          .split("\n")
          .select { |line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty? }
          .prepend(search_path)
          .join("\n")
      end

code link

patch_search_pathを追う

一体どんなことをしているのかと開けてみると、pg_dumpで取得した文字列に対してdefault_tenantに当たる部分をreplaceしていることが分かります。

      def patch_search_path(sql)
        search_path = "SET search_path = \"#{current}\", #{default_tenant};"

        swap_schema_qualifier(sql)
          .split("\n")
          .select { |line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty? }
          .prepend(search_path)
          .join("\n")
      end

      def swap_schema_qualifier(sql)
        sql.gsub(/#{default_tenant}\.\w*/) do |match|
          if Apartment.pg_excluded_names.any? { |name| match.include? name }
            match
          else
            match.gsub("#{default_tenant}.", %("#{current}".))
          end
        end
      end

code link

どうしたら良いか考えてみる

pg_dumpを行った結果を見てみると分かるのですが、create文にもschema名が記述されているため、単純にswap_schema_qualifierを行わないようにすることはできません。 かと言って、「プロシージャ内のロジックだけ置換を行わない」といった事は難しそうです。 プロシージャ内のtable名を動的に生成しEXECUTEで呼び出す方法もありますが、動的にSQL文を生成する分、性能劣化が発生するため今回は使えません。

と思いつつ、ソースを眺めていると、以下のようなコメントアウトされたロジックが目につきました。

      def pg_dump_schema
        # Skip excluded tables? :/
        # excluded_tables =
        #   collect_table_names(Apartment.excluded_models)
        #   .map! {|t| "-T #{t}"}
        #   .join(' ')

        # `pg_dump -s -x -O -n #{default_tenant} #{excluded_tables} #{dbname}`

        with_pg_env { `pg_dump -s -x -O -n #{default_tenant} #{dbname}` }
      end

code link

Apartmentの仕様をもう一度考える

Apartmentとしてどのように動くのが正しいのかを考えてみます

  • excluded_tablesに記載してあるテーブルについては、publicスキーマ上で管理したいテーブルである
  • publicスキーマ以外にもexcluded_tablesが存在するのは、差分が出ないようにするためで、必須ではない

上記であると考えると、コメント部分を復活させて微調整してあげれば解決しそうです。

解決案

以下のようにすれば解決する筈です。

  • テナント追加時に作成するスキーマにexcluded_tablesは存在する必要はないので、pg_dump時の対象から除く
  • swap_schema_qualifierで置換する際に、default_tenant + excluded_tablesの組み合わせだったときは置換させないようにする
    • 事前にpg_dumpの対象からexcluded_tablesに記載のテーブルは除外しているため、プロシージャ内のロジックでのみ出てきます

結論

モンキーパッチを書く

forkしたリポジトリを運用する方法もありますが、今後のバージョンアップに付いていくのが辛いのと、そこまで大きな改修ではないので、今回はモンキーパッチとします。

module PostgresqlSchemaFromSqlAdapterMonkeyPatch
  private

  def pg_dump_schema
    exclude_table = excluded_tables.map! { |t| "-T #{t}" }.join(' ')
    with_pg_env { `pg_dump -s -x -O -n #{default_tenant} #{dbname} #{exclude_table}` }
  end

  def swap_schema_qualifier(sql)
    sql.gsub(/#{default_tenant}\.\w*/) do |match|
      if Apartment.pg_excluded_names.any? { |name| match.include? name }
        match
      elsif excluded_tables.any?(match)
        match
      else
        match.gsub("#{default_tenant}.", %("#{current}".))
      end
    end
  end

  def excluded_tables
    Apartment.excluded_models.map do |m|
      m.constantize.table_name
    end
  end
end

require 'apartment/adapters/postgresql_adapter'
Apartment::Adapters::PostgresqlSchemaFromSqlAdapter.prepend(PostgresqlSchemaFromSqlAdapterMonkeyPatch)

PRを出してみる

今回はそこそこ修正量もあったので、せっかくなのでPull Requestを出してみようと思います。 かくいう私も初めてPull Requestを投げるのでドキドキしながら行ったのですが、以下を参考にしました。

  • Contributingの内容を確認する
    • 大前提ですのでしっかり読んでおきます
  • OSSへPull Requestを投げてみた各種サイト(「oss pull request」とかでググると色々出てきます)
    • 読みやすくまとめてくださっているサイトが色々ありますので、参考にさせていただきました
  • DeepL
    • 英語が得意ではないので、大活躍していただきました

Pull Requestの内容がニッチすぎるせいか、未だに何の反応も無いのが悲しい限りですが、OSSのライブラリに手を加えてPull Requestを投げるまでの一連の流れを体験できたので個人的に良い経験になったな。と、思っています。

最後に

全5回にわたってここまでお付き合いいただきありがとうございました。

次回からは、また別の題材で記事を書きますので(私ではない別のエンジニアになる予定)、ご期待ください。

We're hiring!

DIGGLEでは、必要に応じてライブラリのソースを読み解いて問題解決に挑む開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net

Rails+ApartmentにPostgreSQLのプロシージャを導入して多軸分析の集計速度を向上させた話 その4 ~Rails + plv8編~

こんにちは。DIGGLEエンジニアのzakkyです。

今回でレポート(多軸分析)の集計処理部分のパフォーマンス向上施策の第4弾となります。そろそろゴールが見えてきました。

前回までの記事の紹介

diggle.engineer diggle.engineer diggle.engineer

今回のお題

前回はRails+ApartmentでRails Wayに乗っかりつつPL/pgSQLを導入しましたので、今回は同じ流れでRails+ApartmentでRails Wayに乗っかりつつplv8を導入しようとして一筋縄では行かなかったあれやこれやをご紹介します。

書かないこと

以下の内容は含みませんので予めご了承ください

  • Rails、Apartmentの導入などの基礎的な部分
  • PostgreSQLのプロシージャに関する説明
  • plv8に関する説明

どれくらい速くなったのか

前回も記載しましたが、以下が今回改善した集計処理部分のLatency推移となります。 リリース後には低下していることが見て取れるかと思います。

大体3倍速程度には改善することができました。

赤線の辺りで今回の改善版のリリースを行っています

plv8を導入する

いつも通り早速本題に入ります。

前回はPL/pgSQLをRails Wayで管理する所まで説明しましたので、今回はplv8でも同様にRails Wayに乗っかって管理できるようにしてあげようと思います。 最終的な変更内容については結論に記載していますので、結果だけ知りたい方は最下部を参照ください。

localでplv8を動かす

前段の話にはなりますが、そもそもlocalでplv8が入ったPostgreSQLを用意できないと辛い。ということで、Dockerイメージを用意します。

残念ながらPostgreSQLのDockerイメージにはplv8が入っていないので自前で作る必要があります。 基本的にはplv8のドキュメントを見ながらDockerfileを書くだけの簡単なお仕事ですので、そこまで難しい内容ではありません。

参考までに、私が作ったものを下記に置いておきます。

github.com

db:resetでplv8を適用する

plv8はCREATE EXTENSION plv8を実行してあげる必要があるのですが、structure.sqlに上記内容が含まれないため、db:resetを行った際にplv8が存在しないと怒られてしまいます。

Apartmentのドキュメントに沿って修正する(上手くいかない)

以下のREADMEに沿って修正しても、test側でエラーとなってしまいます。 github.com

test側でエラーになる原因を探る

pg_dumpの仕様として、--schemaオプションを付けてるとExtensionはexportされない

要約すると上記の通りとなります。

Apartmentを使っている以上、前回の記事でpublicテナントだけをダンプ対象にする方法として説明した通り、--schemaオプションを付けない道はありません。また、--schemaではなく--exclude-schemaを付ける事でも解消できるとの記載がありますが、schemaがどんどん増えるapartmentとの相性は考えるまでもなく最悪でしょう。

詳しい内容を知りたい方は下記issueが参考になるかと思います。

github.com github.com

ライブラリのソースを追いかける

上記の時点で、かなり詰んでる気がしますが、まだもう少し頑張ります。

ここからは、db:resetの仕様を把握して何とかできる糸口がないかを探していきます。

ActiveRecordのdb:resetの仕様を読み解く

ソースを読み進めていくと、大きな流れとして、db:drop -> db:create -> db:schema:loadの順に動いているようです。

  task reset: [ "db:drop", "db:setup" ]

  namespace :setup do
    task all: ["db:create", :environment, "db:schema:load", :seed]
    ...(略)...
  end

code link

db:createやdb:schema:loadを読み解く

下記に抜粋した通り、db:createやdb:schema:loadのロジックの中で、database全体に対してループしながらcreateやloadを行っていることが分かりました。今回行いたいこととしては、そのロジックの中でextensionの追加をしてあげる必要があります。

先ほどのApartmentのREADMEで指定された方法では、db:createの後にextensionの追加を行っており、この方法ではデフォルト(≒先頭)のデータベースに向けてしか実行できないことが分かります。(つまり2つ目のデータベースであるtest側のデータベースへは適用できずに今回の問題が発生する)

db:create

      def create_all
        old_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(ActiveRecord::Base.connection_specification_name)
        each_local_configuration { |db_config| create(db_config) }
        if old_pool
          ActiveRecord::Base.connection_handler.establish_connection(old_pool.db_config)
        end
      end

code link

db:schema:load

    namespace :load do
      ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |name|
        ...(略)...
      end
    end

code link

もっとソースを読む

もう少し読み進めてdb:createのロジックを見てみると、↓のcreateの中でextensionを登録できれば何とかなりそうです。

each_local_configuration { |db_config| create(db_config) }

code link

結論

最終手段ではありますが、以下のようなモンキーパッチを当ててあげることで問題を解決しました。

module PostgreSQLDatabaseTasksMonkeyPatch
  def create(*args)
    super(*args)
    ActiveRecord::Base.connection.execute 'CREATE EXTENSION IF NOT EXISTS plv8;'
  end
end
ActiveRecord::Tasks::PostgreSQLDatabaseTasks.prepend(PostgreSQLDatabaseTasksMonkeyPatch)

We're hiring!

DIGGLEでは、ライブラリのソースを読んで必要な時にはモンキーパッチも書く開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net